commands.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  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. self.bridge.register_handler(2073, self.emergency_stop_with_parachute)
  84. self.bridge.register_handler(2074, self.emergency_stop_motors)
  85. def _target(self) -> tuple[int, int]:
  86. source = self.bridge.mavlink_target
  87. if source is None:
  88. raise ValueError("flight controller target is unknown; wait for HEARTBEAT")
  89. return source
  90. def _send(self, message: Any) -> None:
  91. self.bridge.write_mavlink(message.pack(self.encoder))
  92. def get_params(self, command: BaseMessage, bridge: Bridge) -> None:
  93. if command.data is not None:
  94. raise ValueError("DataType 281 Data must be null")
  95. system, component = self._target()
  96. self.request_params(system, component)
  97. def request_params(self, system: int, component: int) -> None:
  98. """Send PARAM_REQUEST_LIST to download the flight controller's table."""
  99. self._send(self.encoder.param_request_list_encode(system, component))
  100. def set_param(self, command: BaseMessage, bridge: Bridge) -> None:
  101. data = command.data
  102. if not isinstance(data, dict):
  103. raise ValueError("DataType 282 Data must be an object")
  104. name = data.get("name")
  105. platform_type = data.get("type")
  106. if not isinstance(name, str) or not name:
  107. raise ValueError("parameter name must be a nonempty string")
  108. if type(platform_type) is not int or platform_type not in (1, 2):
  109. raise ValueError("parameter type must be 1 (number) or 2 (string)")
  110. if name in self.TODO_PARAMS:
  111. LOG.warning("TODO: platform parameter %s has no confirmed VKFly mapping", name)
  112. return
  113. if name == "low_battery_action":
  114. self._set_low_battery_action(data.get("value"))
  115. return
  116. mapping = self.PARAMS.get(name)
  117. if mapping is None:
  118. LOG.warning("TODO: unsupported platform parameter mapping: %s", name)
  119. return
  120. param_id, param_type, converter = mapping
  121. value = data.get("value")
  122. if platform_type == 1 and not _plain(value):
  123. raise ValueError(f"{name} requires a finite numeric value")
  124. try:
  125. converted = converter(value)
  126. except (TypeError, ValueError, KeyError) as exc:
  127. raise ValueError(f"invalid value for {name}: {value!r}") from exc
  128. self._send_param(param_id, converted, param_type)
  129. def _set_low_battery_action(self, value: Any) -> None:
  130. action = _enum_value(value, self.FAILSAFE_ACTIONS)
  131. current = self.bridge.mavlink_param("FS_CONF_A")
  132. if current is None:
  133. raise ValueError(
  134. "FS_CONF_A is unknown; send DataType 281 and wait for parameter download first"
  135. )
  136. # VKFly: bits 0..3 and 4..7 are level-1/level-2 battery protection.
  137. updated = (int(current) & ~0xFF) | action | (action << 4)
  138. self._send_param("FS_CONF_A", float(updated), VKFly.MAV_PARAM_TYPE_UINT32)
  139. def _send_param(self, param_id: str, value: float, param_type: int) -> None:
  140. encoded = param_id.encode("ascii")
  141. if len(encoded) > 16:
  142. raise ValueError(f"MAVLink param_id is longer than 16 bytes: {param_id}")
  143. system, component = self._target()
  144. self._send(self.encoder.param_set_encode(
  145. system, component, encoded, float(value), param_type
  146. ))
  147. def arm(self, command: BaseMessage, bridge: Bridge) -> None:
  148. if type(command.data) is not bool:
  149. raise ValueError("DataType 2050 Data must be a boolean")
  150. if command.data:
  151. # TODO: platform document labels true as "takeover"; its intended
  152. # VKFly behavior must be confirmed before sending a flight command.
  153. LOG.warning("TODO: DataType 2050 Data=true takeover behavior is not implemented")
  154. return
  155. system, component = self._target()
  156. self._send(self.encoder.command_long_encode(
  157. system, component, VKFly.MAV_CMD_COMPONENT_ARM_DISARM, 0,
  158. 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
  159. ))
  160. def emergency_stop_with_parachute(self, command: BaseMessage, bridge: Bridge) -> None:
  161. """DataType 2073: high-altitude emergency motor stop, then parachute release."""
  162. if command.data is not None:
  163. raise ValueError("DataType 2073 Data must be null")
  164. system, component = self._target()
  165. # VKFly MAV_CMD_DO_PARACHUTE with PARACHUTE_RELEASE means
  166. # "Release parachute and kill motors", i.e. stop propellers and deploy.
  167. self._send(self.encoder.command_long_encode(
  168. system, component, VKFly.MAV_CMD_DO_PARACHUTE, 0,
  169. float(VKFly.PARACHUTE_RELEASE), 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
  170. ))
  171. def takeoff(self, command: BaseMessage, bridge: Bridge) -> None:
  172. if command.data is not None:
  173. raise ValueError("DataType 2054 Data must be null")
  174. self._send_command_long(VKFly.MAV_CMD_NAV_TAKEOFF, param7=math.nan)
  175. def land(self, command: BaseMessage, bridge: Bridge) -> None:
  176. if command.data is not None:
  177. raise ValueError("DataType 2055 Data must be null")
  178. self._send_command_long(VKFly.MAV_CMD_NAV_LAND)
  179. def emergency_stop_motors(self, command: BaseMessage, bridge: Bridge) -> None:
  180. """DataType 2074: ground emergency motor stop without parachute."""
  181. if command.data is not None:
  182. raise ValueError("DataType 2074 Data must be null")
  183. system, component = self._target()
  184. # Forced disarm (MAV_CMD_COMPONENT_ARM_DISARM param2=21196) kills the
  185. # motors without triggering the parachute that DataType 2073 uses.
  186. self._send(self.encoder.command_long_encode(
  187. system, component, VKFly.MAV_CMD_COMPONENT_ARM_DISARM, 0,
  188. 0.0, 21196.0, 0.0, 0.0, 0.0, 0.0, 0.0,
  189. ))
  190. def _send_command_long(self, mav_command: int, *, param7: float = math.nan) -> None:
  191. system, component = self._target()
  192. nan = math.nan
  193. self._send(self.encoder.command_long_encode(
  194. system, component, mav_command, 0,
  195. nan, nan, nan, nan, nan, nan, param7,
  196. ))
  197. def reposition(self, command: BaseMessage, bridge: Bridge) -> None:
  198. data = command.data
  199. if not isinstance(data, dict):
  200. raise ValueError("DataType 2070 Data must be an object")
  201. required = ("latitude", "longitude", "altitude", "speed", "heading")
  202. if any(not _plain(data.get(key)) for key in required):
  203. raise ValueError("reposition requires finite latitude/longitude/altitude/speed/heading")
  204. latitude = float(data["latitude"])
  205. longitude = float(data["longitude"])
  206. altitude = float(data["altitude"])
  207. speed = float(data["speed"])
  208. heading = float(data["heading"])
  209. if not -90 <= latitude <= 90 or not -180 <= longitude <= 180:
  210. raise ValueError("invalid reposition latitude or longitude")
  211. if speed < 0 or not 0 <= heading <= 360:
  212. raise ValueError("reposition speed/heading is out of range")
  213. switch_mode = data.get("SwitchModel", True)
  214. if type(switch_mode) is not bool:
  215. raise ValueError("SwitchModel must be a boolean when present")
  216. system, component = self._target()
  217. flags = VKFly.MAV_DO_REPOSITION_FLAGS_CHANGE_MODE if switch_mode else 0
  218. self._send(self.encoder.command_int_encode(
  219. system, component,
  220. VKFly.MAV_FRAME_GLOBAL_RELATIVE_ALT_INT,
  221. VKFly.MAV_CMD_DO_REPOSITION,
  222. 0, 0,
  223. speed, float(flags), math.nan, heading,
  224. round(latitude * 1e7), round(longitude * 1e7), altitude,
  225. ))
  226. def _enum_value(value: Any, names: dict[str, int]) -> int:
  227. if type(value) is int:
  228. if value in names.values():
  229. return value
  230. raise ValueError(f"unsupported enum value: {value}")
  231. if not isinstance(value, str):
  232. raise ValueError(f"enum value must be a string or integer: {value!r}")
  233. key = "".join(character for character in value.strip().lower() if character.isalnum())
  234. if key not in names:
  235. raise ValueError(f"unsupported enum value: {value}")
  236. return names[key]