aircraft.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. """Per-flight-controller state grouped into a single aircraft object.
  2. An :class:`Aircraft` is created only after the first valid flight-controller
  3. HEARTBEAT is locked by :class:`~motian_bridge.telemetry.Telemetry`. It owns the
  4. identity (system/component), the latest raw observations, the vendor battery
  5. packs and the downloaded parameter table. A serial reconnect discards the whole
  6. object, exactly like :class:`Telemetry` itself.
  7. """
  8. from __future__ import annotations
  9. from dataclasses import dataclass
  10. import math
  11. from typing import Any
  12. from .config import Config
  13. from .dialects.v20 import VKFly
  14. # Observations older than this are never used as current flight state.
  15. STALE_SECONDS = 5.0
  16. # VKFly does not define a parameter index for unknown entries.
  17. UNKNOWN_PARAM_INDEX = 0xFFFF
  18. def enum_label(enum: str, value: int, prefix: str) -> str:
  19. entry = VKFly.enums.get(enum, {}).get(value)
  20. return entry.name.removeprefix(prefix) if entry else f"UNKNOWN_{value}"
  21. def distance_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
  22. r1, r2 = math.radians(lat1), math.radians(lat2)
  23. dr = r2 - r1
  24. dl = math.radians(lon2 - lon1)
  25. a = math.sin(dr / 2) ** 2 + math.cos(r1) * math.cos(r2) * math.sin(dl / 2) ** 2
  26. return 6371000.0 * 2 * math.asin(min(1.0, math.sqrt(a)))
  27. def _put_finite(data: dict[str, Any], name: str, value: float) -> None:
  28. if math.isfinite(value):
  29. data[name] = value
  30. @dataclass
  31. class ParamEntry:
  32. """One downloaded parameter, with the metadata VKFly reports."""
  33. value: float
  34. param_type: int | None = None
  35. index: int | None = None
  36. class ParamTable:
  37. """The full parameter table of one aircraft, filled from PARAM_VALUE frames."""
  38. def __init__(self) -> None:
  39. self.entries: dict[str, ParamEntry] = {}
  40. # param_count from the flight controller, once it has been seen.
  41. self.count: int | None = None
  42. # param_index values already received, used to detect a complete download.
  43. self.received: set[int] = set()
  44. def update(self, message: Any) -> None:
  45. """Record one PARAM_VALUE message."""
  46. param_id = message.param_id
  47. if isinstance(param_id, bytes):
  48. param_id = param_id.decode("ascii", "replace")
  49. param_id = param_id.rstrip("\x00")
  50. index = int(message.param_index)
  51. self.entries[param_id] = ParamEntry(
  52. value=float(message.param_value),
  53. param_type=int(message.param_type),
  54. index=index,
  55. )
  56. count = int(message.param_count)
  57. if count > 0:
  58. self.count = count
  59. if index != UNKNOWN_PARAM_INDEX:
  60. self.received.add(index)
  61. def __setitem__(self, param_id: str, value: float | ParamEntry) -> None:
  62. entry = value if isinstance(value, ParamEntry) else ParamEntry(float(value))
  63. self.entries[param_id] = entry
  64. def __contains__(self, param_id: object) -> bool:
  65. return param_id in self.entries
  66. def __len__(self) -> int:
  67. return len(self.entries)
  68. def get(self, param_id: str, default: float | None = None) -> float | None:
  69. entry = self.entries.get(param_id)
  70. return entry.value if entry is not None else default
  71. def complete(self) -> bool:
  72. """True once every indexed parameter has been received."""
  73. return self.count is not None and self.count > 0 and len(self.received) >= self.count
  74. class Aircraft:
  75. """State that belongs to a single flight controller."""
  76. def __init__(self, config: Config, system: int, component: int,
  77. autopilot: int, uav_type: int) -> None:
  78. self.config = config
  79. self.system = system
  80. self.component = component
  81. self.autopilot = autopilot
  82. self.uav_type = uav_type
  83. self.latest: dict[str, tuple[Any, float]] = {}
  84. self.batteries: dict[int, tuple[Any, float]] = {}
  85. self.params = ParamTable()
  86. # Automatic parameter download bookkeeping.
  87. self.param_sync_requested = False
  88. self.param_sync_at: float | None = None
  89. self.param_request_attempts = 0
  90. @property
  91. def source(self) -> tuple[int, int]:
  92. return (self.system, self.component)
  93. def fresh(self, kind: str, now: float) -> Any | None:
  94. entry = self.latest.get(kind)
  95. return entry[0] if entry and 0 <= now - entry[1] <= STALE_SECONDS else None
  96. def param_value(self, param_id: str) -> float | None:
  97. return self.params.get(param_id)
  98. def flight_data(self, now: float, heartbeat: Any) -> dict[str, Any]:
  99. data: dict[str, Any] = {
  100. "SystemID": self.config.system_id,
  101. "Armed": bool(heartbeat.base_mode & VKFly.MAV_MODE_FLAG_SAFETY_ARMED),
  102. "IsFlying": bool(heartbeat.base_mode & VKFly.MAV_MODE_FLAG_SAFETY_ARMED),
  103. "DroneAutopilot": int(heartbeat.autopilot),
  104. "UavType": int(heartbeat.type),
  105. "FlightMode": enum_label(
  106. "VKFLY_CUSTOM_MODE", heartbeat.custom_mode, "VKFLY_CUSTOM_MODE_"
  107. ),
  108. }
  109. attitude = self.fresh("ATTITUDE", now)
  110. if attitude:
  111. _put_finite(data, "Roll", math.degrees(attitude.roll))
  112. _put_finite(data, "Pitch", math.degrees(attitude.pitch))
  113. _put_finite(data, "Yaw", math.degrees(attitude.yaw) % 360)
  114. position = self.fresh("GLOBAL_POSITION_INT", now)
  115. if position:
  116. if abs(position.lat) <= 900000000 and abs(position.lon) <= 1800000000:
  117. data.update(Latitude=position.lat / 1e7, Longitude=position.lon / 1e7)
  118. data.update(
  119. Altitude=position.relative_alt / 1000,
  120. VelocityX=position.vx / 100,
  121. VelocityY=position.vy / 100,
  122. VelocityZ=position.vz / 100,
  123. )
  124. hud = self.fresh("VFR_HUD", now)
  125. if hud:
  126. for key, value in (
  127. ("AirSpeed", hud.airspeed),
  128. ("GroundSpeed", hud.groundspeed),
  129. ("ClimbRate", hud.climb),
  130. ):
  131. _put_finite(data, key, value)
  132. home = self.fresh("HOME_POSITION", now)
  133. if (
  134. home
  135. and abs(home.latitude) <= 900000000
  136. and abs(home.longitude) <= 1800000000
  137. ):
  138. data.update(
  139. HomeLatitude=home.latitude / 1e7,
  140. HomeLongitude=home.longitude / 1e7,
  141. HomeAltitude=home.altitude / 1000,
  142. IsHomeLocationSet=True,
  143. )
  144. if position and "Latitude" in data:
  145. distance = distance_m(
  146. data["Latitude"],
  147. data["Longitude"],
  148. data["HomeLatitude"],
  149. data["HomeLongitude"],
  150. )
  151. data["DistanceToHome"] = distance
  152. data["SpaceDistToHome"] = math.hypot(
  153. distance, position.alt / 1000 - data["HomeAltitude"]
  154. )
  155. gps = self.fresh("GPS_RAW_INT", now)
  156. if gps:
  157. info: dict[str, Any] = {
  158. "Type": 15,
  159. "FixType": enum_label("GPS_FIX_TYPE", gps.fix_type, "GPS_FIX_TYPE_"),
  160. }
  161. if gps.fix_type >= 2:
  162. info["AbsoluteAlt"] = gps.alt / 1000
  163. if gps.eph != 65535:
  164. info["LevelDivisor"] = gps.eph / 100
  165. if gps.epv != 65535:
  166. info["VerticalDivisor"] = gps.epv / 100
  167. if gps.satellites_visible != 255:
  168. info["SatelliteCount"] = gps.satellites_visible
  169. data["Gps"] = info
  170. return data
  171. def battery_data(self, now: float) -> dict[str, Any] | None:
  172. packs = {
  173. id_: item[0]
  174. for id_, item in self.batteries.items()
  175. if 0 <= now - item[1] <= STALE_SECONDS
  176. }
  177. if not packs:
  178. return None
  179. data: dict[str, Any] = {"SystemID": self.config.system_id}
  180. for id_, pack in packs.items():
  181. side = "Left" if id_ == 0 else "Right"
  182. if pack.voltage:
  183. data["Battery_Voltage" + side] = pack.voltage / 1000
  184. data["Battery_Current" + side] = pack.current / 100
  185. if 0 <= pack.cap_percent <= 100:
  186. data["Battery_Remaining" + side] = pack.cap_percent
  187. if len(packs) == 1:
  188. pack = next(iter(packs.values()))
  189. if pack.voltage:
  190. data["Battery_Voltage"] = pack.voltage / 1000
  191. data["Battery_Current"] = pack.current / 100
  192. if 0 <= pack.cap_percent <= 100:
  193. data["Battery_Remaining"] = pack.cap_percent
  194. data["Battery_Temperature"] = pack.temperature
  195. data["cycleIndex"] = pack.cyc_cnt
  196. data["flags"] = pack.err_code
  197. return data