Merge pull request 'feat(safety): remote e-stop over 4G MQTT (Issue #63)' (#69) from sl-firmware/remote-estop into main
This commit is contained in:
commit
fc8faa0dab
@ -12,8 +12,17 @@
|
|||||||
* - RC signal timeout monitoring
|
* - RC signal timeout monitoring
|
||||||
* - Tilt fault alert via buzzer
|
* - Tilt fault alert via buzzer
|
||||||
* - Arm hold interlock (must hold arm for ARMING_HOLD_MS)
|
* - Arm hold interlock (must hold arm for ARMING_HOLD_MS)
|
||||||
|
* - Remote e-stop over 4G MQTT (CDC 'E'/'F'/'Z' commands)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
ESTOP_CLEAR = 0,
|
||||||
|
ESTOP_TILT = 1,
|
||||||
|
ESTOP_RC_KILL = 2,
|
||||||
|
ESTOP_REMOTE = 3,
|
||||||
|
ESTOP_CELLULAR_TIMEOUT = 4,
|
||||||
|
} EstopSource;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* safety_init() — call once in main() after HAL_Init().
|
* safety_init() — call once in main() after HAL_Init().
|
||||||
* Starts IWDG with WATCHDOG_TIMEOUT_MS timeout from config.h.
|
* Starts IWDG with WATCHDOG_TIMEOUT_MS timeout from config.h.
|
||||||
@ -48,4 +57,9 @@ void safety_arm_start(uint32_t now); /* Call when arm requested */
|
|||||||
bool safety_arm_ready(uint32_t now); /* Poll until true, then arm */
|
bool safety_arm_ready(uint32_t now); /* Poll until true, then arm */
|
||||||
void safety_arm_cancel(void); /* Cancel pending arm */
|
void safety_arm_cancel(void); /* Cancel pending arm */
|
||||||
|
|
||||||
|
void safety_remote_estop(EstopSource src);
|
||||||
|
void safety_remote_estop_clear(void);
|
||||||
|
EstopSource safety_get_estop(void);
|
||||||
|
bool safety_remote_estop_active(void);
|
||||||
|
|
||||||
#endif /* SAFETY_H */
|
#endif /* SAFETY_H */
|
||||||
|
|||||||
@ -13,7 +13,8 @@ void status_boot_beep(void);
|
|||||||
* LED1 solid + LED2 off → disarmed, IMU OK
|
* LED1 solid + LED2 off → disarmed, IMU OK
|
||||||
* LED1 solid + LED2 solid → armed
|
* LED1 solid + LED2 solid → armed
|
||||||
* Both slow blink → tilt fault
|
* Both slow blink → tilt fault
|
||||||
|
* Both fast blink (200ms) -- remote e-stop active (highest priority)
|
||||||
* LED1 slow blink + LED2 solid → IMU error (solid LED2 = always-on indicator)
|
* LED1 slow blink + LED2 solid → IMU error (solid LED2 = always-on indicator)
|
||||||
*/
|
*/
|
||||||
void status_update(uint32_t tick, int imu_ok, int armed, int tilt_fault);
|
void status_update(uint32_t tick, int imu_ok, int armed, int tilt_fault, int remote_estop);
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@ -209,10 +209,8 @@ services:
|
|||||||
|
|
||||||
|
|
||||||
# -- Remote e-stop bridge (MQTT over 4G -> STM32 CDC) ----------------------
|
# -- Remote e-stop bridge (MQTT over 4G -> STM32 CDC) ----------------------
|
||||||
# Subscribes to saltybot/estop MQTT topic. {"kill":true} -> 'E
|
# Subscribes to saltybot/estop MQTT topic. {"kill":true} -> 'E\r\n' to STM32.
|
||||||
' to STM32.
|
# Cellular watchdog: 5s MQTT drop in AUTO mode -> 'F\r\n' (ESTOP_CELLULAR_TIMEOUT).
|
||||||
# Cellular watchdog: 5s MQTT drop in AUTO mode -> 'F
|
|
||||||
' (ESTOP_CELLULAR_TIMEOUT).
|
|
||||||
remote-estop:
|
remote-estop:
|
||||||
image: saltybot/ros2-humble:jetson-orin
|
image: saltybot/ros2-humble:jetson-orin
|
||||||
build:
|
build:
|
||||||
|
|||||||
12
jetson/ros2_ws/src/saltybot_bridge/config/estop_params.yaml
Normal file
12
jetson/ros2_ws/src/saltybot_bridge/config/estop_params.yaml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
remote_estop_node:
|
||||||
|
ros__parameters:
|
||||||
|
serial_port: /dev/stm32-bridge
|
||||||
|
baud_rate: 921600
|
||||||
|
mqtt_host: "mqtt.example.com"
|
||||||
|
mqtt_port: 1883
|
||||||
|
mqtt_user: "saltybot"
|
||||||
|
mqtt_password: ""
|
||||||
|
mqtt_topic: "saltybot/estop"
|
||||||
|
cellular_timeout_s: 5.0
|
||||||
|
auto_estop_on_disconnect: true
|
||||||
|
mqtt_keepalive: 10
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
from launch import LaunchDescription
|
||||||
|
from launch_ros.actions import Node
|
||||||
|
import os
|
||||||
|
from ament_index_python.packages import get_package_share_directory
|
||||||
|
|
||||||
|
|
||||||
|
def generate_launch_description():
|
||||||
|
pkg = get_package_share_directory('saltybot_bridge')
|
||||||
|
params_file = os.path.join(pkg, 'config', 'estop_params.yaml')
|
||||||
|
return LaunchDescription([
|
||||||
|
Node(
|
||||||
|
package='saltybot_bridge',
|
||||||
|
executable='remote_estop_node',
|
||||||
|
name='remote_estop_node',
|
||||||
|
parameters=[params_file],
|
||||||
|
output='screen',
|
||||||
|
),
|
||||||
|
])
|
||||||
@ -12,6 +12,7 @@
|
|||||||
<license>MIT</license>
|
<license>MIT</license>
|
||||||
|
|
||||||
<depend>rclpy</depend>
|
<depend>rclpy</depend>
|
||||||
|
<depend>python3-paho-mqtt</depend>
|
||||||
<depend>sensor_msgs</depend>
|
<depend>sensor_msgs</depend>
|
||||||
<depend>std_msgs</depend>
|
<depend>std_msgs</depend>
|
||||||
<depend>diagnostic_msgs</depend>
|
<depend>diagnostic_msgs</depend>
|
||||||
|
|||||||
@ -0,0 +1,167 @@
|
|||||||
|
"""
|
||||||
|
remote_estop_node.py -- Remote e-stop bridge: MQTT -> STM32 USB CDC
|
||||||
|
|
||||||
|
{"kill": true} -> writes 'E\n' to STM32 (ESTOP_REMOTE, immediate motor cutoff)
|
||||||
|
{"kill": false} -> writes 'Z\n' to STM32 (clear latch, robot can re-arm)
|
||||||
|
|
||||||
|
Cellular watchdog: if MQTT link drops for > cellular_timeout_s while in
|
||||||
|
AUTO mode, automatically sends 'F\n' (ESTOP_CELLULAR_TIMEOUT).
|
||||||
|
|
||||||
|
Publishes /saltybot/estop_active (std_msgs/Bool).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import rclpy
|
||||||
|
from rclpy.node import Node
|
||||||
|
from std_msgs.msg import Bool, String
|
||||||
|
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
import serial
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteEstopNode(Node):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__('remote_estop_node')
|
||||||
|
|
||||||
|
self.declare_parameter('serial_port', '/dev/stm32-bridge')
|
||||||
|
self.declare_parameter('baud_rate', 921600)
|
||||||
|
self.declare_parameter('mqtt_host', 'mqtt.example.com')
|
||||||
|
self.declare_parameter('mqtt_port', 1883)
|
||||||
|
self.declare_parameter('mqtt_user', 'saltybot')
|
||||||
|
self.declare_parameter('mqtt_password', '')
|
||||||
|
self.declare_parameter('mqtt_topic', 'saltybot/estop')
|
||||||
|
self.declare_parameter('cellular_timeout_s', 5.0)
|
||||||
|
self.declare_parameter('auto_estop_on_disconnect', True)
|
||||||
|
self.declare_parameter('mqtt_keepalive', 10)
|
||||||
|
|
||||||
|
self._serial_port = self.get_parameter('serial_port').value
|
||||||
|
self._baud_rate = self.get_parameter('baud_rate').value
|
||||||
|
self._mqtt_host = self.get_parameter('mqtt_host').value
|
||||||
|
self._mqtt_port = self.get_parameter('mqtt_port').value
|
||||||
|
self._mqtt_user = self.get_parameter('mqtt_user').value
|
||||||
|
self._mqtt_password = self.get_parameter('mqtt_password').value
|
||||||
|
self._mqtt_topic = self.get_parameter('mqtt_topic').value
|
||||||
|
self._cellular_timeout_s = self.get_parameter('cellular_timeout_s').value
|
||||||
|
self._auto_estop_on_disconnect = self.get_parameter('auto_estop_on_disconnect').value
|
||||||
|
self._mqtt_keepalive = self.get_parameter('mqtt_keepalive').value
|
||||||
|
|
||||||
|
self._ser = serial.Serial(self._serial_port, self._baud_rate, timeout=0.1)
|
||||||
|
self._estop_pub = self.create_publisher(Bool, '/saltybot/estop_active', 10)
|
||||||
|
self._mode_sub = self.create_subscription(
|
||||||
|
String, '/saltybot/balance_state', self._balance_state_cb, 10)
|
||||||
|
self._auto_mode = False
|
||||||
|
self._estop_active = False
|
||||||
|
self._last_mqtt_ok = time.monotonic()
|
||||||
|
self._watchdog_fired = False
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
self._mqtt = mqtt.Client(client_id='saltybot-estop')
|
||||||
|
self._mqtt.username_pw_set(self._mqtt_user, self._mqtt_password)
|
||||||
|
self._mqtt.on_connect = self._on_mqtt_connect
|
||||||
|
self._mqtt.on_disconnect = self._on_mqtt_disconnect
|
||||||
|
self._mqtt.on_message = self._on_mqtt_message
|
||||||
|
self._mqtt.will_set(self._mqtt_topic + '/status', 'OFFLINE', qos=1, retain=True)
|
||||||
|
try:
|
||||||
|
self._mqtt.connect_async(self._mqtt_host, self._mqtt_port, self._mqtt_keepalive)
|
||||||
|
self._mqtt.loop_start()
|
||||||
|
except Exception as e:
|
||||||
|
self.get_logger().error(f'MQTT connect failed: {e}')
|
||||||
|
|
||||||
|
self.create_timer(1.0, self._watchdog_cb)
|
||||||
|
self.get_logger().info(
|
||||||
|
f'remote_estop_node started -- MQTT {self._mqtt_host}:{self._mqtt_port}')
|
||||||
|
|
||||||
|
def _on_mqtt_connect(self, client, userdata, flags, rc):
|
||||||
|
if rc == 0:
|
||||||
|
client.subscribe(self._mqtt_topic, qos=1)
|
||||||
|
with self._lock:
|
||||||
|
self._last_mqtt_ok = time.monotonic()
|
||||||
|
self._watchdog_fired = False
|
||||||
|
else:
|
||||||
|
self.get_logger().warning(f'MQTT connect refused rc={rc}')
|
||||||
|
|
||||||
|
def _on_mqtt_disconnect(self, client, userdata, rc):
|
||||||
|
self.get_logger().warning(f'MQTT disconnected rc={rc}')
|
||||||
|
|
||||||
|
def _on_mqtt_message(self, client, userdata, msg):
|
||||||
|
with self._lock:
|
||||||
|
self._last_mqtt_ok = time.monotonic()
|
||||||
|
self._watchdog_fired = False
|
||||||
|
try:
|
||||||
|
payload = json.loads(msg.payload.decode('utf-8'))
|
||||||
|
kill = payload.get('kill', False)
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||||
|
self.get_logger().error(f'Bad MQTT payload: {e}')
|
||||||
|
return
|
||||||
|
if kill:
|
||||||
|
self._send_estop('E')
|
||||||
|
self.get_logger().warning('MQTT kill=true -> ESTOP_REMOTE')
|
||||||
|
else:
|
||||||
|
self._send_clear()
|
||||||
|
self.get_logger().info('MQTT kill=false -> estop clear')
|
||||||
|
|
||||||
|
def _balance_state_cb(self, msg: String):
|
||||||
|
self._auto_mode = '"md":2' in msg.data
|
||||||
|
|
||||||
|
def _watchdog_cb(self):
|
||||||
|
if not self._auto_estop_on_disconnect:
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
elapsed = time.monotonic() - self._last_mqtt_ok
|
||||||
|
fired = self._watchdog_fired
|
||||||
|
if self._auto_mode and elapsed > self._cellular_timeout_s and not fired:
|
||||||
|
with self._lock:
|
||||||
|
self._watchdog_fired = True
|
||||||
|
self.get_logger().error(
|
||||||
|
f'Cellular link lost {elapsed:.1f}s in AUTO mode -> ESTOP_CELLULAR_TIMEOUT')
|
||||||
|
self._send_estop('F')
|
||||||
|
|
||||||
|
def _send_estop(self, cmd: str):
|
||||||
|
try:
|
||||||
|
self._ser.write(f'{cmd}\n'.encode())
|
||||||
|
self._ser.flush()
|
||||||
|
except serial.SerialException as e:
|
||||||
|
self.get_logger().error(f'Serial write error: {e}')
|
||||||
|
with self._lock:
|
||||||
|
self._estop_active = True
|
||||||
|
msg = Bool()
|
||||||
|
msg.data = True
|
||||||
|
self._estop_pub.publish(msg)
|
||||||
|
|
||||||
|
def _send_clear(self):
|
||||||
|
try:
|
||||||
|
self._ser.write(b'Z\n')
|
||||||
|
self._ser.flush()
|
||||||
|
except serial.SerialException as e:
|
||||||
|
self.get_logger().error(f'Serial write error: {e}')
|
||||||
|
with self._lock:
|
||||||
|
self._estop_active = False
|
||||||
|
msg = Bool()
|
||||||
|
msg.data = False
|
||||||
|
self._estop_pub.publish(msg)
|
||||||
|
|
||||||
|
def destroy_node(self):
|
||||||
|
self._mqtt.loop_stop()
|
||||||
|
self._mqtt.disconnect()
|
||||||
|
if self._ser.is_open:
|
||||||
|
self._ser.close()
|
||||||
|
super().destroy_node()
|
||||||
|
|
||||||
|
|
||||||
|
def main(args=None):
|
||||||
|
rclpy.init(args=args)
|
||||||
|
node = RemoteEstopNode()
|
||||||
|
try:
|
||||||
|
rclpy.spin(node)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
node.destroy_node()
|
||||||
|
rclpy.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@ -12,10 +12,12 @@ setup(
|
|||||||
(f"share/{package_name}/launch", [
|
(f"share/{package_name}/launch", [
|
||||||
"launch/bridge.launch.py",
|
"launch/bridge.launch.py",
|
||||||
"launch/cmd_vel_bridge.launch.py",
|
"launch/cmd_vel_bridge.launch.py",
|
||||||
|
"launch/remote_estop.launch.py",
|
||||||
]),
|
]),
|
||||||
(f"share/{package_name}/config", [
|
(f"share/{package_name}/config", [
|
||||||
"config/bridge_params.yaml",
|
"config/bridge_params.yaml",
|
||||||
"config/cmd_vel_bridge_params.yaml",
|
"config/cmd_vel_bridge_params.yaml",
|
||||||
|
"config/estop_params.yaml",
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
install_requires=["setuptools", "pyserial"],
|
install_requires=["setuptools", "pyserial"],
|
||||||
@ -33,6 +35,7 @@ setup(
|
|||||||
"saltybot_cmd_node = saltybot_bridge.saltybot_cmd_node:main",
|
"saltybot_cmd_node = saltybot_bridge.saltybot_cmd_node:main",
|
||||||
# Nav2 cmd_vel bridge: velocity limits + ramp + deadman + mode gate
|
# Nav2 cmd_vel bridge: velocity limits + ramp + deadman + mode gate
|
||||||
"cmd_vel_bridge_node = saltybot_bridge.cmd_vel_bridge_node:main",
|
"cmd_vel_bridge_node = saltybot_bridge.cmd_vel_bridge_node:main",
|
||||||
|
"remote_estop_node = saltybot_bridge.remote_estop_node:main",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
105
jetson/ros2_ws/src/saltybot_bridge/test/test_remote_estop.py
Normal file
105
jetson/ros2_ws/src/saltybot_bridge/test/test_remote_estop.py
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
"""Unit tests for remote_estop_node."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def make_node(auto_estop=True, timeout=0.2):
|
||||||
|
with patch("saltybot_bridge.remote_estop_node.serial.Serial"), \
|
||||||
|
patch("saltybot_bridge.remote_estop_node.mqtt.Client"), \
|
||||||
|
patch("saltybot_bridge.remote_estop_node.rclpy"), \
|
||||||
|
patch("saltybot_bridge.remote_estop_node.Node.__init__", return_value=None):
|
||||||
|
|
||||||
|
from saltybot_bridge.remote_estop_node import RemoteEstopNode
|
||||||
|
node = RemoteEstopNode.__new__(RemoteEstopNode)
|
||||||
|
node._ser = MagicMock()
|
||||||
|
node._ser.is_open = True
|
||||||
|
node._mqtt = MagicMock()
|
||||||
|
node._mqtt_host = "localhost"
|
||||||
|
node._mqtt_port = 1883
|
||||||
|
node._mqtt_user = "test"
|
||||||
|
node._mqtt_password = ""
|
||||||
|
node._mqtt_topic = "test/estop"
|
||||||
|
node._mqtt_keepalive = 10
|
||||||
|
node._cellular_timeout_s = timeout
|
||||||
|
node._auto_estop_on_disconnect = auto_estop
|
||||||
|
node._auto_mode = False
|
||||||
|
node._estop_active = False
|
||||||
|
node._last_mqtt_ok = time.monotonic()
|
||||||
|
node._watchdog_fired = False
|
||||||
|
node._lock = threading.Lock()
|
||||||
|
node._estop_pub = MagicMock()
|
||||||
|
node.get_logger = MagicMock(return_value=MagicMock(
|
||||||
|
info=MagicMock(), warning=MagicMock(), error=MagicMock()))
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
def mqtt_msg(payload_dict):
|
||||||
|
msg = MagicMock()
|
||||||
|
msg.payload = json.dumps(payload_dict).encode()
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_kill_true_sends_E():
|
||||||
|
node = make_node()
|
||||||
|
t0 = time.monotonic()
|
||||||
|
node._on_mqtt_message(None, None, mqtt_msg({"kill": True}))
|
||||||
|
assert (time.monotonic() - t0) < 0.5
|
||||||
|
node._ser.write.assert_called_once_with(b'E\n')
|
||||||
|
|
||||||
|
|
||||||
|
def test_kill_false_sends_Z():
|
||||||
|
node = make_node()
|
||||||
|
node._on_mqtt_message(None, None, mqtt_msg({"kill": False}))
|
||||||
|
node._ser.write.assert_called_once_with(b'Z\n')
|
||||||
|
|
||||||
|
|
||||||
|
def test_kill_true_publishes_active_true():
|
||||||
|
node = make_node()
|
||||||
|
node._on_mqtt_message(None, None, mqtt_msg({"kill": True}))
|
||||||
|
assert node._estop_pub.publish.call_args[0][0].data is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_json_no_write():
|
||||||
|
node = make_node()
|
||||||
|
bad = MagicMock()
|
||||||
|
bad.payload = b"NOT JSON"
|
||||||
|
node._on_mqtt_message(None, None, bad)
|
||||||
|
node._ser.write.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_watchdog_fires_F_in_auto_after_timeout():
|
||||||
|
node = make_node(auto_estop=True, timeout=0.05)
|
||||||
|
node._auto_mode = True
|
||||||
|
node._last_mqtt_ok = time.monotonic() - 1.0
|
||||||
|
node._watchdog_cb()
|
||||||
|
node._ser.write.assert_called_once_with(b'F\n')
|
||||||
|
|
||||||
|
|
||||||
|
def test_watchdog_no_fire_if_not_auto():
|
||||||
|
node = make_node(auto_estop=True, timeout=0.05)
|
||||||
|
node._auto_mode = False
|
||||||
|
node._last_mqtt_ok = time.monotonic() - 1.0
|
||||||
|
node._watchdog_cb()
|
||||||
|
node._ser.write.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_watchdog_only_fires_once():
|
||||||
|
node = make_node(auto_estop=True, timeout=0.05)
|
||||||
|
node._auto_mode = True
|
||||||
|
node._last_mqtt_ok = time.monotonic() - 1.0
|
||||||
|
node._watchdog_cb()
|
||||||
|
node._watchdog_cb()
|
||||||
|
assert node._ser.write.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_watchdog_disabled_by_param():
|
||||||
|
node = make_node(auto_estop=False, timeout=0.05)
|
||||||
|
node._auto_mode = True
|
||||||
|
node._last_mqtt_ok = time.monotonic() - 1.0
|
||||||
|
node._watchdog_cb()
|
||||||
|
node._ser.write.assert_not_called()
|
||||||
@ -14,4 +14,7 @@ void checkForBootloader(void);
|
|||||||
/* PID tuning command interface (written by USB IRQ, read by main loop) */
|
/* PID tuning command interface (written by USB IRQ, read by main loop) */
|
||||||
extern volatile uint8_t cdc_cmd_ready;
|
extern volatile uint8_t cdc_cmd_ready;
|
||||||
extern volatile char cdc_cmd_buf[32];
|
extern volatile char cdc_cmd_buf[32];
|
||||||
|
|
||||||
|
extern volatile uint8_t cdc_estop_request;
|
||||||
|
extern volatile uint8_t cdc_estop_clear_request;
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@ -9,6 +9,9 @@ volatile uint8_t cdc_disarm_request = 0; /* set by D command */
|
|||||||
volatile uint8_t cdc_recal_request = 0; /* set by G command — gyro recalibration */
|
volatile uint8_t cdc_recal_request = 0; /* set by G command — gyro recalibration */
|
||||||
volatile uint32_t cdc_rx_count = 0; /* total CDC packets received from host */
|
volatile uint32_t cdc_rx_count = 0; /* total CDC packets received from host */
|
||||||
|
|
||||||
|
volatile uint8_t cdc_estop_request = 0;
|
||||||
|
volatile uint8_t cdc_estop_clear_request = 0;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* PID tuning command buffer.
|
* PID tuning command buffer.
|
||||||
* CDC_Receive (USB IRQ) copies multi-char commands here.
|
* CDC_Receive (USB IRQ) copies multi-char commands here.
|
||||||
@ -143,6 +146,10 @@ static int8_t CDC_Receive(uint8_t *buf, uint32_t *len) {
|
|||||||
case 'G': cdc_recal_request = 1; break; /* gyro recalibration */
|
case 'G': cdc_recal_request = 1; break; /* gyro recalibration */
|
||||||
case 'R': request_bootloader(); break; /* never returns */
|
case 'R': request_bootloader(); break; /* never returns */
|
||||||
|
|
||||||
|
case 'E': cdc_estop_request = 1; break;
|
||||||
|
case 'F': cdc_estop_request = 2; break;
|
||||||
|
case 'Z': cdc_estop_clear_request = 1; break;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* PID tuning: P<kp> I<ki> D<kd> T<setpoint> M<max_speed> ?
|
* PID tuning: P<kp> I<ki> D<kd> T<setpoint> M<max_speed> ?
|
||||||
* Copy to cmd buffer; main loop parses float (avoids sscanf in IRQ).
|
* Copy to cmd buffer; main loop parses float (avoids sscanf in IRQ).
|
||||||
|
|||||||
37
src/main.c
37
src/main.c
@ -25,6 +25,8 @@ extern volatile uint8_t cdc_arm_request; /* set by A command */
|
|||||||
extern volatile uint8_t cdc_disarm_request; /* set by D command */
|
extern volatile uint8_t cdc_disarm_request; /* set by D command */
|
||||||
extern volatile uint8_t cdc_recal_request; /* set by G command */
|
extern volatile uint8_t cdc_recal_request; /* set by G command */
|
||||||
extern volatile uint32_t cdc_rx_count; /* total CDC packets received */
|
extern volatile uint32_t cdc_rx_count; /* total CDC packets received */
|
||||||
|
extern volatile uint8_t cdc_estop_request;
|
||||||
|
extern volatile uint8_t cdc_estop_clear_request;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Apply a PID tuning command string from the USB terminal.
|
* Apply a PID tuning command string from the USB terminal.
|
||||||
@ -178,6 +180,20 @@ int main(void) {
|
|||||||
/* Mode manager: update RC liveness, CH6 mode selection, blend ramp */
|
/* Mode manager: update RC liveness, CH6 mode selection, blend ramp */
|
||||||
mode_manager_update(&mode, now);
|
mode_manager_update(&mode, now);
|
||||||
|
|
||||||
|
if (cdc_estop_request) {
|
||||||
|
EstopSource src = (cdc_estop_request == 1) ? ESTOP_REMOTE : ESTOP_CELLULAR_TIMEOUT;
|
||||||
|
cdc_estop_request = 0;
|
||||||
|
safety_remote_estop(src);
|
||||||
|
safety_arm_cancel();
|
||||||
|
balance_disarm(&bal);
|
||||||
|
motor_driver_estop(&motors);
|
||||||
|
}
|
||||||
|
if (cdc_estop_clear_request) {
|
||||||
|
cdc_estop_clear_request = 0;
|
||||||
|
if (safety_remote_estop_active() && bal.state == BALANCE_DISARMED)
|
||||||
|
safety_remote_estop_clear();
|
||||||
|
}
|
||||||
|
|
||||||
/* RC CH5 kill switch: disarm immediately if RC is alive and CH5 off.
|
/* RC CH5 kill switch: disarm immediately if RC is alive and CH5 off.
|
||||||
* Applies regardless of active mode (CH5 always has kill authority). */
|
* Applies regardless of active mode (CH5 always has kill authority). */
|
||||||
if (mode.rc_alive && !crsf_state.armed && bal.state == BALANCE_ARMED) {
|
if (mode.rc_alive && !crsf_state.armed && bal.state == BALANCE_ARMED) {
|
||||||
@ -202,7 +218,8 @@ int main(void) {
|
|||||||
bool rc_armed_now = safety_rc_alive(now) && crsf_state.armed;
|
bool rc_armed_now = safety_rc_alive(now) && crsf_state.armed;
|
||||||
if (rc_armed_now && !s_rc_armed_prev) {
|
if (rc_armed_now && !s_rc_armed_prev) {
|
||||||
/* Rising edge: start arm hold (motors enable after ARMING_HOLD_MS) */
|
/* Rising edge: start arm hold (motors enable after ARMING_HOLD_MS) */
|
||||||
if (mpu6000_is_calibrated() &&
|
if (!safety_remote_estop_active() &&
|
||||||
|
mpu6000_is_calibrated() &&
|
||||||
bal.state == BALANCE_DISARMED && fabsf(bal.pitch_deg) < 10.0f) {
|
bal.state == BALANCE_DISARMED && fabsf(bal.pitch_deg) < 10.0f) {
|
||||||
safety_arm_start(now);
|
safety_arm_start(now);
|
||||||
}
|
}
|
||||||
@ -218,8 +235,8 @@ int main(void) {
|
|||||||
/* Handle arm/disarm requests from USB with arm-hold interlock */
|
/* Handle arm/disarm requests from USB with arm-hold interlock */
|
||||||
if (cdc_arm_request) {
|
if (cdc_arm_request) {
|
||||||
cdc_arm_request = 0;
|
cdc_arm_request = 0;
|
||||||
/* Block arming until gyro calibration is complete */
|
if (!safety_remote_estop_active() &&
|
||||||
if (mpu6000_is_calibrated() &&
|
mpu6000_is_calibrated() &&
|
||||||
bal.state == BALANCE_DISARMED && fabsf(bal.pitch_deg) < 10.0f) {
|
bal.state == BALANCE_DISARMED && fabsf(bal.pitch_deg) < 10.0f) {
|
||||||
safety_arm_start(now);
|
safety_arm_start(now);
|
||||||
}
|
}
|
||||||
@ -269,7 +286,8 @@ int main(void) {
|
|||||||
/* Latch estop on tilt fault or disarm */
|
/* Latch estop on tilt fault or disarm */
|
||||||
if (bal.state == BALANCE_TILT_FAULT) {
|
if (bal.state == BALANCE_TILT_FAULT) {
|
||||||
motor_driver_estop(&motors);
|
motor_driver_estop(&motors);
|
||||||
} else if (bal.state == BALANCE_DISARMED && motors.estop) {
|
} else if (bal.state == BALANCE_DISARMED && motors.estop &&
|
||||||
|
!safety_remote_estop_active()) {
|
||||||
motor_driver_estop_clear(&motors);
|
motor_driver_estop_clear(&motors);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -353,10 +371,14 @@ int main(void) {
|
|||||||
p += n; rem -= n;
|
p += n; rem -= n;
|
||||||
}
|
}
|
||||||
/* Jetson active flag, USB TX/RX packet counters */
|
/* Jetson active flag, USB TX/RX packet counters */
|
||||||
n = snprintf(p, rem, ",\"ja\":%d,\"txc\":%u,\"rxc\":%u}\n",
|
int es; EstopSource _rs = safety_get_estop();
|
||||||
|
if (_rs >= ESTOP_REMOTE) es = (int)_rs;
|
||||||
|
else if (bal.state == BALANCE_TILT_FAULT) es = ESTOP_TILT;
|
||||||
|
else es = ESTOP_CLEAR;
|
||||||
|
n = snprintf(p, rem, ",\"ja\":%d,\"txc\":%u,\"rxc\":%u,\"es\":%d}\n",
|
||||||
jetson_cmd_is_active(now) ? 1 : 0,
|
jetson_cmd_is_active(now) ? 1 : 0,
|
||||||
(unsigned)tx_count,
|
(unsigned)tx_count,
|
||||||
(unsigned)cdc_rx_count);
|
(unsigned)cdc_rx_count, es);
|
||||||
len = (int)(p + n - buf);
|
len = (int)(p + n - buf);
|
||||||
} else {
|
} else {
|
||||||
len = snprintf(buf, sizeof(buf), "{\"err\":%d}\n", imu_ret);
|
len = snprintf(buf, sizeof(buf), "{\"err\":%d}\n", imu_ret);
|
||||||
@ -367,7 +389,8 @@ int main(void) {
|
|||||||
|
|
||||||
status_update(now, (imu_ret == 0),
|
status_update(now, (imu_ret == 0),
|
||||||
(bal.state == BALANCE_ARMED),
|
(bal.state == BALANCE_ARMED),
|
||||||
(bal.state == BALANCE_TILT_FAULT));
|
(bal.state == BALANCE_TILT_FAULT),
|
||||||
|
safety_remote_estop_active());
|
||||||
HAL_Delay(1);
|
HAL_Delay(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,6 +33,8 @@ static bool s_arm_pending = false;
|
|||||||
/* Tilt fault alert state — edge-detect to fire buzzer once */
|
/* Tilt fault alert state — edge-detect to fire buzzer once */
|
||||||
static bool s_was_faulted = false;
|
static bool s_was_faulted = false;
|
||||||
|
|
||||||
|
static EstopSource s_estop_source = ESTOP_CLEAR;
|
||||||
|
|
||||||
void safety_init(void) {
|
void safety_init(void) {
|
||||||
hiwdg.Instance = IWDG;
|
hiwdg.Instance = IWDG;
|
||||||
hiwdg.Init.Prescaler = IWDG_PRESCALER;
|
hiwdg.Init.Prescaler = IWDG_PRESCALER;
|
||||||
@ -76,3 +78,8 @@ bool safety_arm_ready(uint32_t now) {
|
|||||||
void safety_arm_cancel(void) {
|
void safety_arm_cancel(void) {
|
||||||
s_arm_pending = false;
|
s_arm_pending = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void safety_remote_estop(EstopSource src) { s_estop_source = src; }
|
||||||
|
void safety_remote_estop_clear(void) { s_estop_source = ESTOP_CLEAR; }
|
||||||
|
EstopSource safety_get_estop(void) { return s_estop_source; }
|
||||||
|
bool safety_remote_estop_active(void) { return s_estop_source >= ESTOP_REMOTE; }
|
||||||
|
|||||||
@ -42,11 +42,14 @@ void status_boot_beep(void) {
|
|||||||
HAL_GPIO_WritePin(BEEPER_PORT, BEEPER_PIN, GPIO_PIN_RESET);
|
HAL_GPIO_WritePin(BEEPER_PORT, BEEPER_PIN, GPIO_PIN_RESET);
|
||||||
}
|
}
|
||||||
|
|
||||||
void status_update(uint32_t tick, int imu_ok, int armed, int tilt_fault) {
|
void status_update(uint32_t tick, int imu_ok, int armed, int tilt_fault, int remote_estop) {
|
||||||
/* Solid = good, blink = needs attention (1 Hz, 500ms half-period) */
|
GPIO_PinState fast_blink = ((tick / 200) % 2) ? GPIO_PIN_RESET : GPIO_PIN_SET;
|
||||||
GPIO_PinState blink = ((tick / 500) % 2) ? GPIO_PIN_RESET /* ON half */
|
GPIO_PinState blink = ((tick / 500) % 2) ? GPIO_PIN_RESET /* ON half */
|
||||||
: GPIO_PIN_SET; /* OFF half */
|
: GPIO_PIN_SET; /* OFF half */
|
||||||
if (!imu_ok) {
|
if (remote_estop) {
|
||||||
|
HAL_GPIO_WritePin(LED1_PORT, LED1_PIN, fast_blink);
|
||||||
|
HAL_GPIO_WritePin(LED2_PORT, LED2_PIN, fast_blink);
|
||||||
|
} else if (!imu_ok) {
|
||||||
/* IMU error: LED1 blinking (attention), LED2 solid ON */
|
/* IMU error: LED1 blinking (attention), LED2 solid ON */
|
||||||
HAL_GPIO_WritePin(LED1_PORT, LED1_PIN, blink);
|
HAL_GPIO_WritePin(LED1_PORT, LED1_PIN, blink);
|
||||||
HAL_GPIO_WritePin(LED2_PORT, LED2_PIN, GPIO_PIN_RESET);
|
HAL_GPIO_WritePin(LED2_PORT, LED2_PIN, GPIO_PIN_RESET);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user