telemetry.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  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.params: dict[str, float] = {}
  44. self.battery_dirty = False
  45. self.last_flight_report: float | None = None
  46. self.last_battery_report: float | None = None
  47. self.last_armed: bool | None = None
  48. self.last_missing_log: float | None = None
  49. def feed(self, chunk: bytes, now: float | None = None) -> list[tuple[int, Any]]:
  50. now = time.monotonic() if now is None else now
  51. reports: list[tuple[int, Any]] = []
  52. # parse_char returns at most one message, so feed bytes individually.
  53. # Splitting and coalescing serial reads does not affect message framing.
  54. for byte in chunk:
  55. try:
  56. message = self.parser.parse_char(bytes((byte,)))
  57. except VKFly.MAVError as exc:
  58. LOG.debug("discarded invalid MAVLink frame: %s", exc)
  59. continue
  60. if message is not None:
  61. reports.extend(self._handle(message, now))
  62. return reports
  63. def _handle(self, message: Any, now: float) -> list[tuple[int, Any]]:
  64. kind = message.get_type()
  65. src = (message.get_srcSystem(), message.get_srcComponent())
  66. if self.source is None:
  67. if (
  68. kind != "HEARTBEAT"
  69. or src[1] != _AUTOPILOT_COMPONENT
  70. or message.autopilot == _INVALID_AUTOPILOT
  71. ):
  72. return []
  73. self.source = src
  74. LOG.info("locked MAVLink flight controller sys=%s comp=%s", *src)
  75. if src != self.source:
  76. return []
  77. # A BMS frame with an unexpected ID must not appear as a usable pack.
  78. if kind == "VK_BMS_STATUS" and message.bat_id not in (0, 1):
  79. return []
  80. self.latest[kind] = (message, now)
  81. if kind == "HEARTBEAT":
  82. armed = bool(message.base_mode & _ARMED_FLAG)
  83. if armed != self.last_armed:
  84. self.last_armed = armed
  85. # Platform convention: "flying" means armed, not airborne.
  86. return [(274, armed)]
  87. elif kind == "VK_BMS_STATUS" and message.bat_id in (0, 1):
  88. self.batteries[message.bat_id] = (message, now)
  89. self.battery_dirty = True
  90. elif kind == "STATUSTEXT":
  91. # Generated Python text decoding assumes ASCII and irreversibly
  92. # replaces UTF-8 bytes. Read the already CRC-validated frame's
  93. # severity + char[50] wire payload instead.
  94. frame = bytes(message.get_msgbuf())
  95. header_size = 6 if frame[0] == VKFly.PROTOCOL_MARKER_V1 else 10
  96. payload = frame[header_size : header_size + frame[1]]
  97. text = payload[1:51].split(b"\x00", 1)[0]
  98. text = text.decode("utf-8", "replace")
  99. if text:
  100. return [(260, {"severity": int(message.severity), "msg": text})]
  101. elif kind == "PARAM_VALUE":
  102. param_id = message.param_id
  103. if isinstance(param_id, bytes):
  104. param_id = param_id.decode("ascii", "replace")
  105. self.params[param_id.rstrip("\x00")] = float(message.param_value)
  106. return []
  107. def _fresh(self, kind: str, now: float) -> Any | None:
  108. entry = self.latest.get(kind)
  109. return entry[0] if entry and 0 <= now - entry[1] <= STALE_SECONDS else None
  110. def tick(self, now: float | None = None) -> list[tuple[int, Any]]:
  111. now = time.monotonic() if now is None else now
  112. reports: list[tuple[int, Any]] = []
  113. heartbeat = self._fresh("HEARTBEAT", now)
  114. if heartbeat is not None and (
  115. self.last_flight_report is None
  116. or now - self.last_flight_report >= REPORT_SECONDS
  117. ):
  118. reports.append((258, self.flight_data(now, heartbeat)))
  119. self.last_flight_report = now
  120. self._log_missing(now)
  121. if self.battery_dirty and (
  122. self.last_battery_report is None
  123. or now - self.last_battery_report >= REPORT_SECONDS
  124. ):
  125. battery = self.battery_data(now)
  126. self.battery_dirty = False
  127. if battery is not None:
  128. reports.append((257, battery))
  129. self.last_battery_report = now
  130. return reports
  131. def _log_missing(self, now: float) -> None:
  132. if self.last_missing_log is not None and now - self.last_missing_log < 60:
  133. return
  134. missing = [
  135. name
  136. for name in (
  137. "GLOBAL_POSITION_INT",
  138. "ATTITUDE",
  139. "VFR_HUD",
  140. "GPS_RAW_INT",
  141. "HOME_POSITION",
  142. "VK_BMS_STATUS",
  143. )
  144. if self._fresh(name, now) is None
  145. ]
  146. if missing:
  147. LOG.debug("missing/stale MAVLink telemetry: %s", ", ".join(missing))
  148. self.last_missing_log = now
  149. def flight_data(self, now: float, heartbeat: Any) -> dict[str, Any]:
  150. data: dict[str, Any] = {
  151. "SystemID": self.config.system_id,
  152. "Armed": bool(heartbeat.base_mode & _ARMED_FLAG),
  153. "IsFlying": bool(heartbeat.base_mode & _ARMED_FLAG),
  154. "DroneAutopilot": int(heartbeat.autopilot),
  155. "UavType": int(heartbeat.type),
  156. "FlightMode": _enum_label(
  157. "VKFLY_CUSTOM_MODE", heartbeat.custom_mode, "VKFLY_CUSTOM_MODE_"
  158. ),
  159. }
  160. attitude = self._fresh("ATTITUDE", now)
  161. if attitude:
  162. _put_finite(data, "Roll", math.degrees(attitude.roll))
  163. _put_finite(data, "Pitch", math.degrees(attitude.pitch))
  164. _put_finite(data, "Yaw", math.degrees(attitude.yaw) % 360)
  165. position = self._fresh("GLOBAL_POSITION_INT", now)
  166. if position:
  167. if abs(position.lat) <= 900000000 and abs(position.lon) <= 1800000000:
  168. data.update(Latitude=position.lat / 1e7, Longitude=position.lon / 1e7)
  169. data.update(
  170. Altitude=position.relative_alt / 1000,
  171. VelocityX=position.vx / 100,
  172. VelocityY=position.vy / 100,
  173. VelocityZ=position.vz / 100,
  174. )
  175. hud = self._fresh("VFR_HUD", now)
  176. if hud:
  177. for key, value in (
  178. ("AirSpeed", hud.airspeed),
  179. ("GroundSpeed", hud.groundspeed),
  180. ("ClimbRate", hud.climb),
  181. ):
  182. _put_finite(data, key, value)
  183. home = self._fresh("HOME_POSITION", now)
  184. if (
  185. home
  186. and abs(home.latitude) <= 900000000
  187. and abs(home.longitude) <= 1800000000
  188. ):
  189. data.update(
  190. HomeLatitude=home.latitude / 1e7,
  191. HomeLongitude=home.longitude / 1e7,
  192. HomeAltitude=home.altitude / 1000,
  193. IsHomeLocationSet=True,
  194. )
  195. if position and "Latitude" in data:
  196. distance = _distance_m(
  197. data["Latitude"],
  198. data["Longitude"],
  199. data["HomeLatitude"],
  200. data["HomeLongitude"],
  201. )
  202. data["DistanceToHome"] = distance
  203. data["SpaceDistToHome"] = math.hypot(
  204. distance, position.alt / 1000 - data["HomeAltitude"]
  205. )
  206. gps = self._fresh("GPS_RAW_INT", now)
  207. if gps:
  208. info: dict[str, Any] = {
  209. "Type": 15,
  210. "FixType": _enum_label("GPS_FIX_TYPE", gps.fix_type, "GPS_FIX_TYPE_"),
  211. }
  212. if gps.fix_type >= 2:
  213. info["AbsoluteAlt"] = gps.alt / 1000
  214. if gps.eph != 65535:
  215. info["LevelDivisor"] = gps.eph / 100
  216. if gps.epv != 65535:
  217. info["VerticalDivisor"] = gps.epv / 100
  218. if gps.satellites_visible != 255:
  219. info["SatelliteCount"] = gps.satellites_visible
  220. data["Gps"] = info
  221. return data
  222. def battery_data(self, now: float) -> dict[str, Any] | None:
  223. packs = {
  224. id_: item[0]
  225. for id_, item in self.batteries.items()
  226. if 0 <= now - item[1] <= STALE_SECONDS
  227. }
  228. if not packs:
  229. return None
  230. data: dict[str, Any] = {"SystemID": self.config.system_id}
  231. for id_, pack in packs.items():
  232. side = "Left" if id_ == 0 else "Right"
  233. if pack.voltage:
  234. data["Battery_Voltage" + side] = pack.voltage / 1000
  235. data["Battery_Current" + side] = pack.current / 100
  236. if 0 <= pack.cap_percent <= 100:
  237. data["Battery_Remaining" + side] = pack.cap_percent
  238. if len(packs) == 1:
  239. pack = next(iter(packs.values()))
  240. if pack.voltage:
  241. data["Battery_Voltage"] = pack.voltage / 1000
  242. data["Battery_Current"] = pack.current / 100
  243. if 0 <= pack.cap_percent <= 100:
  244. data["Battery_Remaining"] = pack.cap_percent
  245. data["Battery_Temperature"] = pack.temperature
  246. data["cycleIndex"] = pack.cyc_cnt
  247. data["flags"] = pack.err_code
  248. return data