commands.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. """MQTT business commands translated to VKFly MAVLink messages."""
  2. from __future__ import annotations
  3. import logging
  4. import math
  5. from typing import TYPE_CHECKING, Any, Callable
  6. from .dialects.v20 import VKFly
  7. if TYPE_CHECKING:
  8. from .bridge import BaseMessage, Bridge
  9. LOG = logging.getLogger(__name__)
  10. GCS_SYSTEM_ID = 255
  11. GCS_COMPONENT_ID = VKFly.MAV_COMP_ID_MISSIONPLANNER
  12. def _plain(value: Any) -> bool:
  13. return type(value) in (int, float) and math.isfinite(float(value))
  14. class FlightCommandHandlers:
  15. """Built-in handlers for the platform commands supported by VKFly."""
  16. PARAMS: dict[str, tuple[str, int, Callable[[Any], float]]] = {
  17. "battery_low_threshold": ("VCAP1_LOW_VAL", VKFly.MAV_PARAM_TYPE_UINT8, float),
  18. "battery_critical_threshold": ("VCAP2_LOW_VAL", VKFly.MAV_PARAM_TYPE_UINT8, float),
  19. "height_limit": ("ALT_LIM_UP1", VKFly.MAV_PARAM_TYPE_UINT16, float),
  20. "distance_limit": ("MAX_HOR_DIST", VKFly.MAV_PARAM_TYPE_UINT32, float),
  21. "return_height": ("RTL_ALT_M", VKFly.MAV_PARAM_TYPE_REAL32, float),
  22. "return_speed": ("MC_XY_CRUISE", VKFly.MAV_PARAM_TYPE_REAL32, float),
  23. "obstacle_avoidance_enable": (
  24. "OBAVOID_ACT", VKFly.MAV_PARAM_TYPE_UINT8, lambda value: float(1 if value else 0)
  25. ),
  26. "obstacle_avoidance_distance": (
  27. "OBAVOID_DIST", VKFly.MAV_PARAM_TYPE_REAL32, float
  28. ),
  29. "obstacle_avoidance_switch": (
  30. "OBAVOID_ACT", VKFly.MAV_PARAM_TYPE_UINT8, lambda value: float(1 if value else 0)
  31. ),
  32. "return_type": (
  33. "RTL_EXEC_MODE",
  34. VKFly.MAV_PARAM_TYPE_UINT8,
  35. lambda value: float(_enum_value(value, {
  36. "normal": 0, "straight": 0, "direct": 0,
  37. "mission": 1, "missionsequence": 1, "sequence": 1,
  38. "missionreverse": 2, "reverse": 2,
  39. })),
  40. ),
  41. "precision_land_mode": (
  42. "RTL_PLD_EN",
  43. VKFly.MAV_PARAM_TYPE_UINT32,
  44. lambda value: float(_enum_value(value, {
  45. "disabled": 0, "off": 0, "none": 0,
  46. "apriltag": 1, "qrcode": 1, "enabled": 1, "on": 1,
  47. })),
  48. ),
  49. }
  50. TODO_PARAMS = {
  51. # No third capacity threshold exists in the current VKFly parameter table.
  52. "battery_emergency_threshold",
  53. "geofence_action",
  54. "visual_sensor_enable",
  55. "april_tag_id",
  56. "data_link_loss_action",
  57. "rc_loss_action",
  58. "parachute_safety_enable",
  59. "four_g_lost_action",
  60. # NAV_DFLT_SATID selects a navigation source; it is not an RTK switch.
  61. "rtk_switch",
  62. }
  63. FAILSAFE_ACTIONS = {
  64. "none": 0, "noaction": 0,
  65. "hover": 1, "loiter": 1,
  66. "return": 2, "returnhome": 2, "rtl": 2,
  67. "rally": 3,
  68. "land": 4, "landing": 4,
  69. "disarm": 5, "lock": 5, "terminate": 5,
  70. }
  71. def __init__(self, bridge: Bridge):
  72. self.bridge = bridge
  73. self.encoder = VKFly.MAVLink(
  74. None, srcSystem=GCS_SYSTEM_ID, srcComponent=GCS_COMPONENT_ID
  75. )
  76. def install(self) -> None:
  77. self.bridge.register_handler(281, self.get_params)
  78. self.bridge.register_handler(282, self.set_param)
  79. self.bridge.register_handler(2050, self.arm)
  80. self.bridge.register_handler(2054, self.takeoff)
  81. self.bridge.register_handler(2055, self.land)
  82. self.bridge.register_handler(2070, self.reposition)
  83. def _target(self) -> tuple[int, int]:
  84. source = self.bridge.mavlink_target
  85. if source is None:
  86. raise ValueError("flight controller target is unknown; wait for HEARTBEAT")
  87. return source
  88. def _send(self, message: Any) -> None:
  89. self.bridge.write_mavlink(message.pack(self.encoder))
  90. def get_params(self, command: BaseMessage, bridge: Bridge) -> None:
  91. if command.data is not None:
  92. raise ValueError("DataType 281 Data must be null")
  93. system, component = self._target()
  94. self.request_params(system, component)
  95. def request_params(self, system: int, component: int) -> None:
  96. """Send PARAM_REQUEST_LIST to download the flight controller's table."""
  97. self._send(self.encoder.param_request_list_encode(system, component))
  98. def set_param(self, command: BaseMessage, bridge: Bridge) -> None:
  99. data = command.data
  100. if not isinstance(data, dict):
  101. raise ValueError("DataType 282 Data must be an object")
  102. name = data.get("name")
  103. platform_type = data.get("type")
  104. if not isinstance(name, str) or not name:
  105. raise ValueError("parameter name must be a nonempty string")
  106. if type(platform_type) is not int or platform_type not in (1, 2):
  107. raise ValueError("parameter type must be 1 (number) or 2 (string)")
  108. if name in self.TODO_PARAMS:
  109. LOG.warning("TODO: platform parameter %s has no confirmed VKFly mapping", name)
  110. return
  111. if name == "low_battery_action":
  112. self._set_low_battery_action(data.get("value"))
  113. return
  114. mapping = self.PARAMS.get(name)
  115. if mapping is None:
  116. LOG.warning("TODO: unsupported platform parameter mapping: %s", name)
  117. return
  118. param_id, param_type, converter = mapping
  119. value = data.get("value")
  120. if platform_type == 1 and not _plain(value):
  121. raise ValueError(f"{name} requires a finite numeric value")
  122. try:
  123. converted = converter(value)
  124. except (TypeError, ValueError, KeyError) as exc:
  125. raise ValueError(f"invalid value for {name}: {value!r}") from exc
  126. self._send_param(param_id, converted, param_type)
  127. def _set_low_battery_action(self, value: Any) -> None:
  128. action = _enum_value(value, self.FAILSAFE_ACTIONS)
  129. current = self.bridge.mavlink_param("FS_CONF_A")
  130. if current is None:
  131. raise ValueError(
  132. "FS_CONF_A is unknown; send DataType 281 and wait for parameter download first"
  133. )
  134. # VKFly: bits 0..3 and 4..7 are level-1/level-2 battery protection.
  135. updated = (int(current) & ~0xFF) | action | (action << 4)
  136. self._send_param("FS_CONF_A", float(updated), VKFly.MAV_PARAM_TYPE_UINT32)
  137. def _send_param(self, param_id: str, value: float, param_type: int) -> None:
  138. encoded = param_id.encode("ascii")
  139. if len(encoded) > 16:
  140. raise ValueError(f"MAVLink param_id is longer than 16 bytes: {param_id}")
  141. system, component = self._target()
  142. self._send(self.encoder.param_set_encode(
  143. system, component, encoded, float(value), param_type
  144. ))
  145. def arm(self, command: BaseMessage, bridge: Bridge) -> None:
  146. if type(command.data) is not bool:
  147. raise ValueError("DataType 2050 Data must be a boolean")
  148. if command.data:
  149. # TODO: platform document labels true as "takeover"; its intended
  150. # VKFly behavior must be confirmed before sending a flight command.
  151. LOG.warning("TODO: DataType 2050 Data=true takeover behavior is not implemented")
  152. return
  153. system, component = self._target()
  154. self._send(self.encoder.command_long_encode(
  155. system, component, VKFly.MAV_CMD_COMPONENT_ARM_DISARM, 0,
  156. 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
  157. ))
  158. def takeoff(self, command: BaseMessage, bridge: Bridge) -> None:
  159. if command.data is not None:
  160. raise ValueError("DataType 2054 Data must be null")
  161. self._send_command_long(VKFly.MAV_CMD_NAV_TAKEOFF, param7=math.nan)
  162. def land(self, command: BaseMessage, bridge: Bridge) -> None:
  163. if command.data is not None:
  164. raise ValueError("DataType 2055 Data must be null")
  165. self._send_command_long(VKFly.MAV_CMD_NAV_LAND)
  166. def _send_command_long(self, mav_command: int, *, param7: float = math.nan) -> None:
  167. system, component = self._target()
  168. nan = math.nan
  169. self._send(self.encoder.command_long_encode(
  170. system, component, mav_command, 0,
  171. nan, nan, nan, nan, nan, nan, param7,
  172. ))
  173. def reposition(self, command: BaseMessage, bridge: Bridge) -> None:
  174. data = command.data
  175. if not isinstance(data, dict):
  176. raise ValueError("DataType 2070 Data must be an object")
  177. required = ("latitude", "longitude", "altitude", "speed", "heading")
  178. if any(not _plain(data.get(key)) for key in required):
  179. raise ValueError("reposition requires finite latitude/longitude/altitude/speed/heading")
  180. latitude = float(data["latitude"])
  181. longitude = float(data["longitude"])
  182. altitude = float(data["altitude"])
  183. speed = float(data["speed"])
  184. heading = float(data["heading"])
  185. if not -90 <= latitude <= 90 or not -180 <= longitude <= 180:
  186. raise ValueError("invalid reposition latitude or longitude")
  187. if speed < 0 or not 0 <= heading <= 360:
  188. raise ValueError("reposition speed/heading is out of range")
  189. switch_mode = data.get("SwitchModel", True)
  190. if type(switch_mode) is not bool:
  191. raise ValueError("SwitchModel must be a boolean when present")
  192. system, component = self._target()
  193. flags = VKFly.MAV_DO_REPOSITION_FLAGS_CHANGE_MODE if switch_mode else 0
  194. self._send(self.encoder.command_int_encode(
  195. system, component,
  196. VKFly.MAV_FRAME_GLOBAL_RELATIVE_ALT_INT,
  197. VKFly.MAV_CMD_DO_REPOSITION,
  198. 0, 0,
  199. speed, float(flags), math.nan, heading,
  200. round(latitude * 1e7), round(longitude * 1e7), altitude,
  201. ))
  202. def _enum_value(value: Any, names: dict[str, int]) -> int:
  203. if type(value) is int:
  204. if value in names.values():
  205. return value
  206. raise ValueError(f"unsupported enum value: {value}")
  207. if not isinstance(value, str):
  208. raise ValueError(f"enum value must be a string or integer: {value!r}")
  209. key = "".join(character for character in value.strip().lower() if character.isalnum())
  210. if key not in names:
  211. raise ValueError(f"unsupported enum value: {value}")
  212. return names[key]