bridge.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. """MQTT / serial bridge with optional raw forwarding and parsed telemetry."""
  2. from dataclasses import dataclass
  3. from datetime import datetime
  4. import json
  5. import logging
  6. import re
  7. import threading
  8. from typing import Any, Callable
  9. from .config import Config
  10. from .telemetry import Telemetry
  11. LOG = logging.getLogger(__name__)
  12. _HEX = re.compile(r"(?:[0-9a-fA-F]{2})+")
  13. MAVLINK_PASSTHROUGH = 526
  14. MAX_MQTT_PAYLOAD = 65536
  15. @dataclass(frozen=True)
  16. class BaseMessage:
  17. data_type: int
  18. uav_type: int
  19. data: Any
  20. system_id: str
  21. timestamp: str
  22. def decode_command(payload: bytes, config: Config) -> BaseMessage:
  23. """Validate the Base envelope. Data for future handlers is left untouched."""
  24. if len(payload) > MAX_MQTT_PAYLOAD:
  25. raise ValueError("MQTT payload too large")
  26. try:
  27. value = json.loads(payload.decode("utf-8"))
  28. except (UnicodeError, json.JSONDecodeError) as exc:
  29. raise ValueError("invalid JSON Base payload") from exc
  30. if not isinstance(value, dict):
  31. raise ValueError("Base payload must be an object")
  32. if value.get("SystemID") != config.system_id:
  33. raise ValueError("SystemID does not match this bridge")
  34. data_type = value.get("DataType")
  35. if type(data_type) is not int:
  36. raise ValueError("DataType must be an integer")
  37. uav_type = value.get("UAVType")
  38. if type(uav_type) is not int or uav_type != config.uav_type:
  39. raise ValueError("UAVType does not match this bridge")
  40. if "Data" not in value:
  41. raise ValueError("Data is required")
  42. timestamp = value.get("TimeStamp")
  43. if not isinstance(timestamp, str):
  44. raise ValueError("TimeStamp must be a string")
  45. return BaseMessage(data_type, uav_type, value["Data"], value["SystemID"], timestamp)
  46. def decode_hex(data: Any, max_bytes: int) -> bytes:
  47. if not isinstance(data, str) or len(data) > 2 * max_bytes or not _HEX.fullmatch(data):
  48. raise ValueError("Data must be nonempty even-length HEX within the configured limit")
  49. return bytes.fromhex(data)
  50. def make_report(config: Config, data_type: int, data: Any) -> bytes:
  51. if type(data_type) is not int:
  52. raise ValueError("DataType must be an integer")
  53. envelope = {
  54. "DataType": data_type,
  55. "UAVType": config.uav_type,
  56. "Data": data,
  57. "SystemID": config.system_id,
  58. "TimeStamp": datetime.now().astimezone().isoformat(timespec="milliseconds"),
  59. }
  60. return json.dumps(envelope, ensure_ascii=False, allow_nan=False,
  61. separators=(",", ":")).encode("utf-8")
  62. Handler = Callable[[BaseMessage, "Bridge"], None]
  63. class Bridge:
  64. """Uses injected MQTT/serial objects; the CLI owns concrete dependencies."""
  65. def __init__(self, config: Config, mqtt_client: Any, serial_factory: Callable[..., Any]):
  66. self.config = config
  67. self.mqtt = mqtt_client
  68. self.serial_factory = serial_factory
  69. self._handlers: dict[int, Handler] = {}
  70. self._mqtt_connected = threading.Event()
  71. self._stop = threading.Event()
  72. self._serial_lock = threading.Lock()
  73. self._serial: Any = None
  74. self._thread: threading.Thread | None = None
  75. self._telemetry = Telemetry(config)
  76. from .commands import FlightCommandHandlers
  77. self._flight_commands = FlightCommandHandlers(self)
  78. self._flight_commands.install()
  79. self.mqtt.on_connect = self._on_connect
  80. self.mqtt.on_disconnect = self._on_disconnect
  81. self.mqtt.on_message = self._on_message
  82. def register_handler(self, data_type: int, handler: Handler) -> None:
  83. """Register a future business-command handler (526 remains reserved)."""
  84. if type(data_type) is not int or data_type == MAVLINK_PASSTHROUGH or not callable(handler):
  85. raise ValueError("handler requires a non-526 integer DataType and a callable")
  86. self._handlers[data_type] = handler
  87. @property
  88. def mavlink_target(self) -> tuple[int, int] | None:
  89. """Flight-controller system/component selected from its HEARTBEAT."""
  90. return self._telemetry.source
  91. def mavlink_param(self, param_id: str) -> float | None:
  92. """Return the latest parameter value learned from PARAM_VALUE."""
  93. return self._telemetry.params.get(param_id)
  94. def write_mavlink(self, frame: bytes) -> None:
  95. """Write a generated MAVLink frame to the flight-controller serial port."""
  96. self._write_serial(frame)
  97. def publish_report(self, data_type: int, data: Any, *, passthrough: bool = False) -> bool:
  98. """Publish future reports to /RECEIVE, or raw data to /PUBLISH."""
  99. if not self._mqtt_connected.is_set():
  100. return False
  101. topic = self.config.publish_topic if passthrough else self.config.receive_topic
  102. try:
  103. payload = make_report(self.config, data_type, data)
  104. except (TypeError, ValueError):
  105. LOG.exception("invalid report data; message discarded")
  106. return False
  107. try:
  108. result = self.mqtt.publish(topic, payload, qos=0, retain=False)
  109. except Exception:
  110. LOG.exception("MQTT publish failed")
  111. return False
  112. if result.rc != 0:
  113. LOG.warning("MQTT publish failed: rc=%s", result.rc)
  114. return False
  115. return True
  116. def start(self) -> None:
  117. self._stop.clear()
  118. self._thread = threading.Thread(target=self._serial_loop, name="serial-reader", daemon=True)
  119. self._thread.start()
  120. self.mqtt.connect_async(self.config.mqtt_host, self.config.mqtt_port, self.config.mqtt_keepalive)
  121. self.mqtt.loop_start()
  122. def stop(self) -> None:
  123. self._stop.set()
  124. self._mqtt_connected.clear()
  125. self.mqtt.disconnect()
  126. self.mqtt.loop_stop()
  127. with self._serial_lock:
  128. serial_port, self._serial = self._serial, None
  129. if serial_port is not None:
  130. serial_port.close()
  131. if self._thread is not None:
  132. self._thread.join(timeout=3)
  133. def _on_connect(self, client: Any, userdata: Any, flags: Any, reason_code: Any, properties: Any) -> None:
  134. if reason_code != 0:
  135. self._mqtt_connected.clear()
  136. LOG.error("MQTT connection refused: %s", reason_code)
  137. return
  138. result, _mid = client.subscribe(self.config.obtain_topic, qos=0)
  139. if result != 0:
  140. LOG.error("MQTT subscribe failed: rc=%s", result)
  141. self._mqtt_connected.clear()
  142. return
  143. self._mqtt_connected.set()
  144. LOG.info("MQTT connected; subscribed to %s", self.config.obtain_topic)
  145. def _on_disconnect(self, client: Any, userdata: Any, disconnect_flags: Any, reason_code: Any, properties: Any) -> None:
  146. self._mqtt_connected.clear()
  147. LOG.warning("MQTT disconnected: %s", reason_code)
  148. def _on_message(self, client: Any, userdata: Any, message: Any) -> None:
  149. if message.topic != self.config.obtain_topic or message.retain or not self._mqtt_connected.is_set():
  150. LOG.warning("ignored wrong-topic, retained, or disconnected MQTT command")
  151. return
  152. try:
  153. command = decode_command(message.payload, self.config)
  154. if command.data_type == MAVLINK_PASSTHROUGH:
  155. self._write_serial(decode_hex(command.data, self.config.max_command_bytes))
  156. else:
  157. handler = self._handlers.get(command.data_type)
  158. if handler is None:
  159. LOG.warning("unsupported DataType=%s; command discarded", command.data_type)
  160. else:
  161. handler(command, self)
  162. except ValueError as exc:
  163. LOG.warning("invalid MQTT command: %s", exc)
  164. except Exception:
  165. LOG.exception("command handler failed")
  166. def _write_serial(self, data: bytes) -> None:
  167. with self._serial_lock:
  168. if self._serial is None:
  169. LOG.warning("serial disconnected; command discarded")
  170. return
  171. try:
  172. count = self._serial.write(data)
  173. if count != len(data):
  174. raise OSError(f"short serial write: {count}/{len(data)}")
  175. except Exception:
  176. LOG.exception("serial write failed; command discarded")
  177. self._serial.close()
  178. self._serial = None
  179. def _serial_loop(self) -> None:
  180. while not self._stop.is_set():
  181. with self._serial_lock:
  182. current = self._serial
  183. if current is None:
  184. try:
  185. current = self.serial_factory(
  186. self.config.serial_port,
  187. self.config.serial_baudrate,
  188. timeout=0.2,
  189. write_timeout=0.5,
  190. )
  191. with self._serial_lock:
  192. if self._stop.is_set():
  193. current.close()
  194. return
  195. self._serial = current
  196. # A fresh serial link can be another flight controller.
  197. self._telemetry = Telemetry(self.config)
  198. LOG.info("serial connected: %s", self.config.serial_port)
  199. except Exception as exc:
  200. LOG.warning("serial connection failed: %s", exc)
  201. self._stop.wait(self.config.reconnect_seconds)
  202. continue
  203. try:
  204. data = current.read(self.config.max_chunk_bytes)
  205. except Exception as exc:
  206. if self._stop.is_set():
  207. break
  208. LOG.warning("serial read failed: %s", exc)
  209. with self._serial_lock:
  210. if self._serial is current:
  211. self._serial = None
  212. current.close()
  213. self._stop.wait(self.config.reconnect_seconds)
  214. continue
  215. try:
  216. if data and self.config.publish_raw_mavlink and self._mqtt_connected.is_set():
  217. self.publish_report(MAVLINK_PASSTHROUGH, data.hex().upper(), passthrough=True)
  218. if data:
  219. for data_type, report in self._telemetry.feed(data):
  220. self.publish_report(data_type, report)
  221. for data_type, report in self._telemetry.tick():
  222. self.publish_report(data_type, report)
  223. except Exception:
  224. LOG.exception("telemetry processing failed; serial link preserved")