telemetry.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. """State derived from checksummed VKFly MAVLink messages.
  2. The wire parser and cached message sources live on the serial reader thread only.
  3. Times below are monotonic receive times, never the flight controller's boot clock.
  4. """
  5. import logging
  6. import math
  7. import time
  8. from typing import Any
  9. from .config import Config
  10. from .dialects.v20 import VKFly
  11. LOG = logging.getLogger(__name__)
  12. # The generated module honors MAV_IGNORE_CRC from the environment by default.
  13. # Never permit that override for telemetry used as flight state.
  14. VKFly.MAVLINK_IGNORE_CRC = 0
  15. STALE_SECONDS = 5.0
  16. REPORT_SECONDS = 1.0
  17. _INVALID_AUTOPILOT = VKFly.MAV_AUTOPILOT_INVALID
  18. _AUTOPILOT_COMPONENT = VKFly.MAV_COMP_ID_AUTOPILOT1
  19. _ARMED_FLAG = VKFly.MAV_MODE_FLAG_SAFETY_ARMED
  20. def _enum_label(enum: str, value: int, prefix: str) -> str:
  21. entry = VKFly.enums.get(enum, {}).get(value)
  22. return entry.name.removeprefix(prefix) if entry else f"UNKNOWN_{value}"
  23. def _distance_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
  24. r1, r2 = math.radians(lat1), math.radians(lat2)
  25. dr = r2 - r1
  26. dl = math.radians(lon2 - lon1)
  27. a = math.sin(dr / 2) ** 2 + math.cos(r1) * math.cos(r2) * math.sin(dl / 2) ** 2
  28. return 6371000.0 * 2 * math.asin(min(1.0, math.sqrt(a)))
  29. def _put_finite(data: dict[str, Any], name: str, value: float) -> None:
  30. if math.isfinite(value):
  31. data[name] = value
  32. class Telemetry:
  33. """Parse MAVLink v1/v2 bytes and emit (DataType, Data) reports.
  34. Neither the parser nor state is shared across serial reconnects. Non-flight
  35. components and other systems never contribute to the selected UAV's state.
  36. """
  37. def __init__(self, config: Config):
  38. self.config = config
  39. self.parser = VKFly.MAVLink(None)
  40. self.source: tuple[int, int] | None = None
  41. self.latest: dict[str, tuple[Any, float]] = {}
  42. self.batteries: dict[int, tuple[Any, float]] = {}
  43. self.battery_dirty = False
  44. self.last_flight_report: float | None = None
  45. self.last_battery_report: float | None = None
  46. self.last_armed: bool | None = None
  47. self.last_missing_log: float | None = None
  48. def feed(self, chunk: bytes, now: float | None = None) -> list[tuple[int, Any]]:
  49. now = time.monotonic() if now is None else now
  50. reports: list[tuple[int, Any]] = []
  51. # parse_char returns at most one message, so feed bytes individually.
  52. # Splitting and coalescing serial reads does not affect message framing.
  53. for byte in chunk:
  54. try:
  55. message = self.parser.parse_char(bytes((byte,)))
  56. except VKFly.MAVError as exc:
  57. LOG.debug("discarded invalid MAVLink frame: %s", exc)
  58. continue
  59. if message is not None:
  60. reports.extend(self._handle(message, now))
  61. return reports
  62. def _handle(self, message: Any, now: float) -> list[tuple[int, Any]]:
  63. kind = message.get_type()
  64. src = (message.get_srcSystem(), message.get_srcComponent())
  65. if self.source is None:
  66. if (kind != "HEARTBEAT" or src[1] != _AUTOPILOT_COMPONENT
  67. or message.autopilot == _INVALID_AUTOPILOT):
  68. return []
  69. self.source = src
  70. LOG.info("locked MAVLink flight controller sys=%s comp=%s", *src)
  71. if src != self.source:
  72. return []
  73. # A BMS frame with an unexpected ID must not appear as a usable pack.
  74. if kind == "VK_BMS_STATUS" and message.bat_id not in (0, 1):
  75. return []
  76. self.latest[kind] = (message, now)
  77. if kind == "HEARTBEAT":
  78. armed = bool(message.base_mode & _ARMED_FLAG)
  79. if armed != self.last_armed:
  80. self.last_armed = armed
  81. # Platform convention: "flying" means armed, not airborne.
  82. return [(274, armed)]
  83. elif kind == "VK_BMS_STATUS" and message.bat_id in (0, 1):
  84. self.batteries[message.bat_id] = (message, now)
  85. self.battery_dirty = True
  86. elif kind == "STATUSTEXT":
  87. # Generated Python text decoding assumes ASCII and irreversibly
  88. # replaces UTF-8 bytes. Read the already CRC-validated frame's
  89. # severity + char[50] wire payload instead.
  90. frame = bytes(message.get_msgbuf())
  91. header_size = 6 if frame[0] == VKFly.PROTOCOL_MARKER_V1 else 10
  92. payload = frame[header_size:header_size + frame[1]]
  93. text = payload[1:51].split(b"\x00", 1)[0]
  94. text = text.decode("utf-8", "replace")
  95. if text:
  96. return [(260, {"severity": int(message.severity), "msg": text})]
  97. return []
  98. def _fresh(self, kind: str, now: float) -> Any | None:
  99. entry = self.latest.get(kind)
  100. return entry[0] if entry and 0 <= now - entry[1] <= STALE_SECONDS else None
  101. def tick(self, now: float | None = None) -> list[tuple[int, Any]]:
  102. now = time.monotonic() if now is None else now
  103. reports: list[tuple[int, Any]] = []
  104. heartbeat = self._fresh("HEARTBEAT", now)
  105. if heartbeat is not None and (self.last_flight_report is None
  106. or now - self.last_flight_report >= REPORT_SECONDS):
  107. reports.append((258, self.flight_data(now, heartbeat)))
  108. self.last_flight_report = now
  109. self._log_missing(now)
  110. if self.battery_dirty and (self.last_battery_report is None
  111. or now - self.last_battery_report >= REPORT_SECONDS):
  112. battery = self.battery_data(now)
  113. self.battery_dirty = False
  114. if battery is not None:
  115. reports.append((257, battery))
  116. self.last_battery_report = now
  117. return reports
  118. def _log_missing(self, now: float) -> None:
  119. if self.last_missing_log is not None and now - self.last_missing_log < 60:
  120. return
  121. missing = [name for name in ("GLOBAL_POSITION_INT", "ATTITUDE", "VFR_HUD",
  122. "GPS_RAW_INT", "HOME_POSITION", "VK_BMS_STATUS")
  123. if self._fresh(name, now) is None]
  124. if missing:
  125. LOG.debug("missing/stale MAVLink telemetry: %s", ", ".join(missing))
  126. self.last_missing_log = now
  127. def flight_data(self, now: float, heartbeat: Any) -> dict[str, Any]:
  128. data: dict[str, Any] = {
  129. "SystemID": self.config.system_id,
  130. "Armed": bool(heartbeat.base_mode & _ARMED_FLAG),
  131. "IsFlying": bool(heartbeat.base_mode & _ARMED_FLAG),
  132. "DroneAutopilot": int(heartbeat.autopilot),
  133. "UavType": int(heartbeat.type),
  134. "FlightMode": _enum_label("VKFLY_CUSTOM_MODE", heartbeat.custom_mode,
  135. "VKFLY_CUSTOM_MODE_"),
  136. }
  137. attitude = self._fresh("ATTITUDE", now)
  138. if attitude:
  139. _put_finite(data, "Roll", math.degrees(attitude.roll))
  140. _put_finite(data, "Pitch", math.degrees(attitude.pitch))
  141. _put_finite(data, "Yaw", math.degrees(attitude.yaw) % 360)
  142. position = self._fresh("GLOBAL_POSITION_INT", now)
  143. if position:
  144. if abs(position.lat) <= 900000000 and abs(position.lon) <= 1800000000:
  145. data.update(Latitude=position.lat / 1e7, Longitude=position.lon / 1e7)
  146. data.update(Altitude=position.relative_alt / 1000,
  147. VelocityX=position.vx / 100, VelocityY=position.vy / 100,
  148. VelocityZ=position.vz / 100)
  149. hud = self._fresh("VFR_HUD", now)
  150. if hud:
  151. for key, value in (("AirSpeed", hud.airspeed), ("GroundSpeed", hud.groundspeed),
  152. ("ClimbRate", hud.climb)):
  153. _put_finite(data, key, value)
  154. home = self._fresh("HOME_POSITION", now)
  155. if home and abs(home.latitude) <= 900000000 and abs(home.longitude) <= 1800000000:
  156. data.update(HomeLatitude=home.latitude / 1e7, HomeLongitude=home.longitude / 1e7,
  157. HomeAltitude=home.altitude / 1000, IsHomeLocationSet=True)
  158. if position and "Latitude" in data:
  159. distance = _distance_m(data["Latitude"], data["Longitude"],
  160. data["HomeLatitude"], data["HomeLongitude"])
  161. data["DistanceToHome"] = distance
  162. data["SpaceDistToHome"] = math.hypot(
  163. distance, position.alt / 1000 - data["HomeAltitude"])
  164. gps = self._fresh("GPS_RAW_INT", now)
  165. if gps:
  166. info: dict[str, Any] = {
  167. "Type": 15,
  168. "FixType": _enum_label("GPS_FIX_TYPE", gps.fix_type, "GPS_FIX_TYPE_"),
  169. }
  170. if gps.fix_type >= 2:
  171. info["AbsoluteAlt"] = gps.alt / 1000
  172. if gps.eph != 65535:
  173. info["LevelDivisor"] = gps.eph / 100
  174. if gps.epv != 65535:
  175. info["VerticalDivisor"] = gps.epv / 100
  176. if gps.satellites_visible != 255:
  177. info["SatelliteCount"] = gps.satellites_visible
  178. data["Gps"] = info
  179. return data
  180. def battery_data(self, now: float) -> dict[str, Any] | None:
  181. packs = {id_: item[0] for id_, item in self.batteries.items()
  182. if 0 <= now - item[1] <= STALE_SECONDS}
  183. if not packs:
  184. return None
  185. data: dict[str, Any] = {"SystemID": self.config.system_id}
  186. for id_, pack in packs.items():
  187. side = "Left" if id_ == 0 else "Right"
  188. if pack.voltage:
  189. data["Battery_Voltage" + side] = pack.voltage / 1000
  190. data["Battery_Current" + side] = pack.current / 100
  191. if 0 <= pack.cap_percent <= 100:
  192. data["Battery_Remaining" + side] = pack.cap_percent
  193. if len(packs) == 1:
  194. pack = next(iter(packs.values()))
  195. if pack.voltage:
  196. data["Battery_Voltage"] = pack.voltage / 1000
  197. data["Battery_Current"] = pack.current / 100
  198. if 0 <= pack.cap_percent <= 100:
  199. data["Battery_Remaining"] = pack.cap_percent
  200. data["Battery_Temperature"] = pack.temperature
  201. data["cycleIndex"] = pack.cyc_cnt
  202. data["flags"] = pack.err_code
  203. return data