| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229 |
- """Per-flight-controller state grouped into a single aircraft object.
- An :class:`Aircraft` is created only after the first valid flight-controller
- HEARTBEAT is locked by :class:`~motian_bridge.telemetry.Telemetry`. It owns the
- identity (system/component), the latest raw observations, the vendor battery
- packs and the downloaded parameter table. A serial reconnect discards the whole
- object, exactly like :class:`Telemetry` itself.
- """
- from __future__ import annotations
- from dataclasses import dataclass
- import math
- from typing import Any
- from .config import Config
- from .dialects.v20 import VKFly
- # Observations older than this are never used as current flight state.
- STALE_SECONDS = 5.0
- # VKFly does not define a parameter index for unknown entries.
- UNKNOWN_PARAM_INDEX = 0xFFFF
- def enum_label(enum: str, value: int, prefix: str) -> str:
- entry = VKFly.enums.get(enum, {}).get(value)
- return entry.name.removeprefix(prefix) if entry else f"UNKNOWN_{value}"
- def distance_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
- r1, r2 = math.radians(lat1), math.radians(lat2)
- dr = r2 - r1
- dl = math.radians(lon2 - lon1)
- a = math.sin(dr / 2) ** 2 + math.cos(r1) * math.cos(r2) * math.sin(dl / 2) ** 2
- return 6371000.0 * 2 * math.asin(min(1.0, math.sqrt(a)))
- def _put_finite(data: dict[str, Any], name: str, value: float) -> None:
- if math.isfinite(value):
- data[name] = value
- @dataclass
- class ParamEntry:
- """One downloaded parameter, with the metadata VKFly reports."""
- value: float
- param_type: int | None = None
- index: int | None = None
- class ParamTable:
- """The full parameter table of one aircraft, filled from PARAM_VALUE frames."""
- def __init__(self) -> None:
- self.entries: dict[str, ParamEntry] = {}
- # param_count from the flight controller, once it has been seen.
- self.count: int | None = None
- # param_index values already received, used to detect a complete download.
- self.received: set[int] = set()
- def update(self, message: Any) -> None:
- """Record one PARAM_VALUE message."""
- param_id = message.param_id
- if isinstance(param_id, bytes):
- param_id = param_id.decode("ascii", "replace")
- param_id = param_id.rstrip("\x00")
- index = int(message.param_index)
- self.entries[param_id] = ParamEntry(
- value=float(message.param_value),
- param_type=int(message.param_type),
- index=index,
- )
- count = int(message.param_count)
- if count > 0:
- self.count = count
- if index != UNKNOWN_PARAM_INDEX:
- self.received.add(index)
- def __setitem__(self, param_id: str, value: float | ParamEntry) -> None:
- entry = value if isinstance(value, ParamEntry) else ParamEntry(float(value))
- self.entries[param_id] = entry
- def __contains__(self, param_id: object) -> bool:
- return param_id in self.entries
- def __len__(self) -> int:
- return len(self.entries)
- def get(self, param_id: str, default: float | None = None) -> float | None:
- entry = self.entries.get(param_id)
- return entry.value if entry is not None else default
- def complete(self) -> bool:
- """True once every indexed parameter has been received."""
- return self.count is not None and self.count > 0 and len(self.received) >= self.count
- class Aircraft:
- """State that belongs to a single flight controller."""
- def __init__(self, config: Config, system: int, component: int,
- autopilot: int, uav_type: int) -> None:
- self.config = config
- self.system = system
- self.component = component
- self.autopilot = autopilot
- self.uav_type = uav_type
- self.latest: dict[str, tuple[Any, float]] = {}
- self.batteries: dict[int, tuple[Any, float]] = {}
- self.params = ParamTable()
- # Automatic parameter download bookkeeping.
- self.param_sync_requested = False
- self.param_sync_at: float | None = None
- self.param_request_attempts = 0
- @property
- def source(self) -> tuple[int, int]:
- return (self.system, self.component)
- def fresh(self, kind: str, now: float) -> Any | None:
- entry = self.latest.get(kind)
- return entry[0] if entry and 0 <= now - entry[1] <= STALE_SECONDS else None
- def param_value(self, param_id: str) -> float | None:
- return self.params.get(param_id)
- def flight_data(self, now: float, heartbeat: Any) -> dict[str, Any]:
- data: dict[str, Any] = {
- "SystemID": self.config.system_id,
- "Armed": bool(heartbeat.base_mode & VKFly.MAV_MODE_FLAG_SAFETY_ARMED),
- "IsFlying": bool(heartbeat.base_mode & VKFly.MAV_MODE_FLAG_SAFETY_ARMED),
- "DroneAutopilot": int(heartbeat.autopilot),
- "UavType": int(heartbeat.type),
- "FlightMode": enum_label(
- "VKFLY_CUSTOM_MODE", heartbeat.custom_mode, "VKFLY_CUSTOM_MODE_"
- ),
- }
- attitude = self.fresh("ATTITUDE", now)
- if attitude:
- _put_finite(data, "Roll", math.degrees(attitude.roll))
- _put_finite(data, "Pitch", math.degrees(attitude.pitch))
- _put_finite(data, "Yaw", math.degrees(attitude.yaw) % 360)
- position = self.fresh("GLOBAL_POSITION_INT", now)
- if position:
- if abs(position.lat) <= 900000000 and abs(position.lon) <= 1800000000:
- data.update(Latitude=position.lat / 1e7, Longitude=position.lon / 1e7)
- data.update(
- Altitude=position.relative_alt / 1000,
- VelocityX=position.vx / 100,
- VelocityY=position.vy / 100,
- VelocityZ=position.vz / 100,
- )
- hud = self.fresh("VFR_HUD", now)
- if hud:
- for key, value in (
- ("AirSpeed", hud.airspeed),
- ("GroundSpeed", hud.groundspeed),
- ("ClimbRate", hud.climb),
- ):
- _put_finite(data, key, value)
- home = self.fresh("HOME_POSITION", now)
- if (
- home
- and abs(home.latitude) <= 900000000
- and abs(home.longitude) <= 1800000000
- ):
- data.update(
- HomeLatitude=home.latitude / 1e7,
- HomeLongitude=home.longitude / 1e7,
- HomeAltitude=home.altitude / 1000,
- IsHomeLocationSet=True,
- )
- if position and "Latitude" in data:
- distance = distance_m(
- data["Latitude"],
- data["Longitude"],
- data["HomeLatitude"],
- data["HomeLongitude"],
- )
- data["DistanceToHome"] = distance
- data["SpaceDistToHome"] = math.hypot(
- distance, position.alt / 1000 - data["HomeAltitude"]
- )
- gps = self.fresh("GPS_RAW_INT", now)
- if gps:
- info: dict[str, Any] = {
- "Type": 15,
- "FixType": enum_label("GPS_FIX_TYPE", gps.fix_type, "GPS_FIX_TYPE_"),
- }
- if gps.fix_type >= 2:
- info["AbsoluteAlt"] = gps.alt / 1000
- if gps.eph != 65535:
- info["LevelDivisor"] = gps.eph / 100
- if gps.epv != 65535:
- info["VerticalDivisor"] = gps.epv / 100
- if gps.satellites_visible != 255:
- info["SatelliteCount"] = gps.satellites_visible
- data["Gps"] = info
- return data
- def battery_data(self, now: float) -> dict[str, Any] | None:
- packs = {
- id_: item[0]
- for id_, item in self.batteries.items()
- if 0 <= now - item[1] <= STALE_SECONDS
- }
- if not packs:
- return None
- data: dict[str, Any] = {"SystemID": self.config.system_id}
- for id_, pack in packs.items():
- side = "Left" if id_ == 0 else "Right"
- if pack.voltage:
- data["Battery_Voltage" + side] = pack.voltage / 1000
- data["Battery_Current" + side] = pack.current / 100
- if 0 <= pack.cap_percent <= 100:
- data["Battery_Remaining" + side] = pack.cap_percent
- if len(packs) == 1:
- pack = next(iter(packs.values()))
- if pack.voltage:
- data["Battery_Voltage"] = pack.voltage / 1000
- data["Battery_Current"] = pack.current / 100
- if 0 <= pack.cap_percent <= 100:
- data["Battery_Remaining"] = pack.cap_percent
- data["Battery_Temperature"] = pack.temperature
- data["cycleIndex"] = pack.cyc_cnt
- data["flags"] = pack.err_code
- return data
|