telemetry.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. """State derived from checksummed VKFly MAVLink messages.
  2. The wire parser and cached message sources live on the serial reader thread only.
  3. Observations are grouped per flight controller in an
  4. :class:`~motian_bridge.aircraft.Aircraft`. Times below are monotonic receive
  5. times, never the flight controller's boot clock.
  6. """
  7. import logging
  8. import time
  9. from typing import Any
  10. from .aircraft import Aircraft, ParamTable
  11. from .config import Config
  12. from .dialects.v20 import VKFly
  13. LOG = logging.getLogger(__name__)
  14. # The generated module honors MAV_IGNORE_CRC from the environment by default.
  15. # Never permit that override for telemetry used as flight state.
  16. VKFly.MAVLINK_IGNORE_CRC = 0
  17. REPORT_SECONDS = 1.0
  18. _INVALID_AUTOPILOT = VKFly.MAV_AUTOPILOT_INVALID
  19. _AUTOPILOT_COMPONENT = VKFly.MAV_COMP_ID_AUTOPILOT1
  20. _ARMED_FLAG = VKFly.MAV_MODE_FLAG_SAFETY_ARMED
  21. class Telemetry:
  22. """Parse MAVLink v1/v2 bytes and emit (DataType, Data) reports.
  23. Neither the parser nor the current :class:`Aircraft` is shared across serial
  24. reconnects. Non-flight components and other systems never contribute to the
  25. selected UAV's state.
  26. """
  27. def __init__(self, config: Config):
  28. self.config = config
  29. self.parser = VKFly.MAVLink(None)
  30. self.aircraft: Aircraft | None = None
  31. self._empty_params = ParamTable()
  32. self.battery_dirty = False
  33. self.last_flight_report: float | None = None
  34. self.last_battery_report: float | None = None
  35. self.last_armed: bool | None = None
  36. self.last_missing_log: float | None = None
  37. # Backwards-compatible views onto the currently locked aircraft. These are
  38. # empty until the first valid flight-controller HEARTBEAT arrives.
  39. @property
  40. def source(self) -> tuple[int, int] | None:
  41. return self.aircraft.source if self.aircraft is not None else None
  42. @property
  43. def latest(self) -> dict[str, tuple[Any, float]]:
  44. return self.aircraft.latest if self.aircraft is not None else {}
  45. @property
  46. def batteries(self) -> dict[int, tuple[Any, float]]:
  47. return self.aircraft.batteries if self.aircraft is not None else {}
  48. @property
  49. def params(self) -> ParamTable:
  50. return self.aircraft.params if self.aircraft is not None else self._empty_params
  51. def param_value(self, param_id: str) -> float | None:
  52. return self.params.get(param_id)
  53. def feed(self, chunk: bytes, now: float | None = None) -> list[tuple[int, Any]]:
  54. now = time.monotonic() if now is None else now
  55. reports: list[tuple[int, Any]] = []
  56. # parse_char returns at most one message, so feed bytes individually.
  57. # Splitting and coalescing serial reads does not affect message framing.
  58. for byte in chunk:
  59. try:
  60. message = self.parser.parse_char(bytes((byte,)))
  61. except VKFly.MAVError as exc:
  62. LOG.debug("discarded invalid MAVLink frame: %s", exc)
  63. continue
  64. if message is not None:
  65. reports.extend(self._handle(message, now))
  66. return reports
  67. def _lock(self, message: Any, src: tuple[int, int]) -> None:
  68. self.aircraft = Aircraft(
  69. self.config, src[0], src[1], int(message.autopilot), int(message.type)
  70. )
  71. LOG.info("locked MAVLink flight controller sys=%s comp=%s", *src)
  72. def _handle(self, message: Any, now: float) -> list[tuple[int, Any]]:
  73. kind = message.get_type()
  74. src = (message.get_srcSystem(), message.get_srcComponent())
  75. if self.aircraft is None:
  76. if (
  77. kind != "HEARTBEAT"
  78. or src[1] != _AUTOPILOT_COMPONENT
  79. or message.autopilot == _INVALID_AUTOPILOT
  80. ):
  81. return []
  82. self._lock(message, src)
  83. if src != self.aircraft.source:
  84. return []
  85. # A BMS frame with an unexpected ID must not appear as a usable pack.
  86. if kind == "VK_BMS_STATUS" and message.bat_id not in (0, 1):
  87. return []
  88. self.aircraft.latest[kind] = (message, now)
  89. if kind == "HEARTBEAT":
  90. armed = bool(message.base_mode & _ARMED_FLAG)
  91. if armed != self.last_armed:
  92. self.last_armed = armed
  93. # Platform convention: "flying" means armed, not airborne.
  94. return [(274, armed)]
  95. elif kind == "VK_BMS_STATUS" and message.bat_id in (0, 1):
  96. self.aircraft.batteries[message.bat_id] = (message, now)
  97. self.battery_dirty = True
  98. elif kind == "STATUSTEXT":
  99. # Generated Python text decoding assumes ASCII and irreversibly
  100. # replaces UTF-8 bytes. Read the already CRC-validated frame's
  101. # severity + char[50] wire payload instead.
  102. frame = bytes(message.get_msgbuf())
  103. header_size = 6 if frame[0] == VKFly.PROTOCOL_MARKER_V1 else 10
  104. payload = frame[header_size : header_size + frame[1]]
  105. text = payload[1:51].split(b"\x00", 1)[0]
  106. text = text.decode("utf-8", "replace")
  107. if text:
  108. return [(260, {"severity": int(message.severity), "msg": text})]
  109. elif kind == "PARAM_VALUE":
  110. self.aircraft.params.update(message)
  111. return []
  112. def tick(self, now: float | None = None) -> list[tuple[int, Any]]:
  113. now = time.monotonic() if now is None else now
  114. reports: list[tuple[int, Any]] = []
  115. aircraft = self.aircraft
  116. if aircraft is None:
  117. return reports
  118. heartbeat = aircraft.fresh("HEARTBEAT", now)
  119. if heartbeat is not None and (
  120. self.last_flight_report is None
  121. or now - self.last_flight_report >= REPORT_SECONDS
  122. ):
  123. reports.append((258, aircraft.flight_data(now, heartbeat)))
  124. self.last_flight_report = now
  125. self._log_missing(aircraft, now)
  126. if self.battery_dirty and (
  127. self.last_battery_report is None
  128. or now - self.last_battery_report >= REPORT_SECONDS
  129. ):
  130. battery = aircraft.battery_data(now)
  131. self.battery_dirty = False
  132. if battery is not None:
  133. reports.append((257, battery))
  134. self.last_battery_report = now
  135. return reports
  136. def _log_missing(self, aircraft: Aircraft, now: float) -> None:
  137. if self.last_missing_log is not None and now - self.last_missing_log < 60:
  138. return
  139. missing = [
  140. name
  141. for name in (
  142. "GLOBAL_POSITION_INT",
  143. "ATTITUDE",
  144. "VFR_HUD",
  145. "GPS_RAW_INT",
  146. "HOME_POSITION",
  147. "VK_BMS_STATUS",
  148. )
  149. if aircraft.fresh(name, now) is None
  150. ]
  151. if missing:
  152. LOG.debug("missing/stale MAVLink telemetry: %s", ", ".join(missing))
  153. self.last_missing_log = now