test_telemetry.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. import json
  2. import math
  3. import time
  4. import unittest
  5. from motian_bridge import Bridge, Config
  6. from motian_bridge.dialects.v20 import VKFly
  7. from motian_bridge.telemetry import Telemetry
  8. def cfg(**kwargs):
  9. fields = dict(system_id="UAV000001", uav_type=7, serial_port="loop://",
  10. serial_baudrate=57600, mqtt_host="localhost")
  11. fields.update(kwargs)
  12. return Config(**fields)
  13. def sender(system=1, component=1):
  14. return VKFly.MAVLink(None, srcSystem=system, srcComponent=component)
  15. def heartbeat(mav, armed=False, mode=4):
  16. return mav.heartbeat_encode(
  17. VKFly.MAV_TYPE_QUADROTOR, VKFly.MAV_AUTOPILOT_GENERIC,
  18. VKFly.MAV_MODE_FLAG_SAFETY_ARMED if armed else 0,
  19. mode, VKFly.MAV_STATE_ACTIVE).pack(mav)
  20. def bms(mav, id_=0, voltage=22800, current=350, percent=76):
  21. return mav.vk_bms_status_encode(
  22. 1000, voltage, current, 28, percent, id_, 3, 6,
  23. [0] * 30, 42, 95).pack(mav)
  24. def position(mav):
  25. return mav.global_position_int_encode(
  26. 1000, 312304000, 1214737000, 135500, 120500,
  27. 520, 110, -30, 8730).pack(mav)
  28. def decode_frame(frame):
  29. parser = VKFly.MAVLink(None)
  30. result = None
  31. for byte in frame:
  32. message = parser.parse_char(bytes((byte,)))
  33. if message is not None:
  34. result = message
  35. return result
  36. class ParserTests(unittest.TestCase):
  37. def test_split_frames_crc_and_v1_heartbeat(self):
  38. telemetry = Telemetry(cfg())
  39. mav = sender()
  40. frame = heartbeat(mav, True)
  41. self.assertEqual(telemetry.feed(frame[:7], now=10), [])
  42. self.assertEqual(telemetry.feed(frame[7:] + position(mav), now=10),
  43. [(274, True)])
  44. data = dict(telemetry.tick(now=10))[258]
  45. self.assertAlmostEqual(data["Latitude"], 31.2304)
  46. self.assertAlmostEqual(data["Altitude"], 120.5)
  47. self.assertEqual(data["VelocityZ"], -0.3)
  48. self.assertEqual(data["FlightMode"], "POSHOLD")
  49. corrupted = bytearray(position(mav))
  50. corrupted[-1] ^= 0xFF
  51. telemetry.feed(corrupted, now=11)
  52. # Corrupted position must not replace the good observation.
  53. self.assertEqual(telemetry.latest["GLOBAL_POSITION_INT"][1], 10)
  54. self.assertEqual(telemetry.feed(heartbeat(mav, False), now=12), [(274, False)])
  55. mav_v1 = sender()
  56. frame_v1 = mav_v1.heartbeat_encode(
  57. VKFly.MAV_TYPE_QUADROTOR, VKFly.MAV_AUTOPILOT_GENERIC, 128, 12,
  58. VKFly.MAV_STATE_ACTIVE).pack(mav_v1, force_mavlink1=True)
  59. self.assertEqual(frame_v1[0], 0xFE)
  60. self.assertEqual(Telemetry(cfg()).feed(frame_v1, now=1), [(274, True)])
  61. def test_flight_units_freshness_and_partial_state(self):
  62. telemetry = Telemetry(cfg())
  63. mav = sender()
  64. telemetry.feed(heartbeat(mav), now=0)
  65. initial = dict(telemetry.tick(now=0))[258]
  66. self.assertNotIn("Latitude", initial)
  67. self.assertEqual(initial["Armed"], False)
  68. telemetry.feed(mav.attitude_encode(
  69. 1000, -0.5, 1.2, -math.pi / 2, 0, 0, 0).pack(mav), now=0.5)
  70. telemetry.feed(position(mav), now=0.5)
  71. telemetry.feed(mav.vfr_hud_encode(
  72. 12.4, 13.1, 87, 50, 135.5, 0.8).pack(mav), now=0.5)
  73. telemetry.feed(mav.home_position_encode(
  74. 312280000, 1214700000, 15000, 0, 0, 0, [1, 0, 0, 0],
  75. 0, 0, 0).pack(mav), now=0.5)
  76. telemetry.feed(mav.gps_raw_int_encode(
  77. 1000000, 3, 312304000, 1214737000, 135500,
  78. 80, 110, 1300, 8700, 18).pack(mav), now=0.5)
  79. self.assertEqual(telemetry.tick(now=0.9), [])
  80. state = dict(telemetry.tick(now=1))[258]
  81. self.assertAlmostEqual(state["Roll"], math.degrees(-0.5), places=4)
  82. self.assertAlmostEqual(state["Pitch"], math.degrees(1.2), places=4)
  83. self.assertAlmostEqual(state["Yaw"], 270, places=4)
  84. self.assertAlmostEqual(state["GroundSpeed"], 13.1, places=5)
  85. self.assertEqual(state["HomeAltitude"], 15)
  86. self.assertGreater(state["SpaceDistToHome"], state["DistanceToHome"])
  87. self.assertEqual(state["Gps"]["SatelliteCount"], 18)
  88. self.assertEqual(state["Gps"]["LevelDivisor"], 0.8)
  89. self.assertNotIn("Temperature", state)
  90. self.assertNotIn("FlyTime", state)
  91. self.assertNotIn("IsMotorsOn", state)
  92. # Heartbeat is fresh, but all other observations have expired.
  93. telemetry.feed(heartbeat(mav), now=5.6)
  94. stale = dict(telemetry.tick(now=5.6))[258]
  95. self.assertNotIn("Latitude", stale)
  96. self.assertNotIn("Gps", stale)
  97. self.assertNotIn("HomeLatitude", stale)
  98. self.assertEqual(telemetry.tick(now=11), [])
  99. self.assertEqual(telemetry.feed(heartbeat(mav), now=11), [])
  100. self.assertEqual(dict(telemetry.tick(now=11))[258]["FlightMode"], "POSHOLD")
  101. def test_lock_to_first_flight_controller_and_reset(self):
  102. telemetry = Telemetry(cfg())
  103. gcs = sender(10, 190)
  104. foreign = sender(2, 1)
  105. selected = sender(1, 1)
  106. telemetry.feed(heartbeat(gcs), now=0)
  107. self.assertIsNone(telemetry.source)
  108. telemetry.feed(heartbeat(selected, True), now=0)
  109. telemetry.feed(heartbeat(foreign, False), now=0)
  110. telemetry.feed(position(foreign), now=0)
  111. self.assertEqual(telemetry.source, (1, 1))
  112. self.assertEqual(dict(telemetry.tick(now=0))[258]["Armed"], True)
  113. self.assertNotIn("Latitude", dict(telemetry.tick(now=1))[258])
  114. replacement = Telemetry(cfg())
  115. self.assertEqual(replacement.feed(heartbeat(foreign), now=2), [(274, False)])
  116. self.assertEqual(replacement.source, (2, 1))
  117. def test_bms_only_two_packs_rate_limit_and_stale(self):
  118. telemetry = Telemetry(cfg())
  119. mav = sender()
  120. telemetry.feed(heartbeat(mav), now=0)
  121. telemetry.feed(bms(mav, 0), now=0)
  122. single = dict(telemetry.tick(now=0))[257]
  123. self.assertEqual(single["Battery_VoltageLeft"], 22.8)
  124. self.assertEqual(single["Battery_Current"], 3.5)
  125. self.assertEqual(single["Battery_Temperature"], 28)
  126. self.assertEqual(single["cycleIndex"], 42)
  127. self.assertEqual(single["flags"], 3)
  128. telemetry.feed(bms(mav, 1, voltage=23000, current=-120, percent=50), now=0.2)
  129. self.assertEqual(telemetry.tick(now=0.2), [])
  130. dual = dict(telemetry.tick(now=1))[257]
  131. self.assertEqual(dual["Battery_VoltageRight"], 23)
  132. self.assertEqual(dual["Battery_CurrentRight"], -1.2)
  133. self.assertNotIn("Battery_Voltage", dual)
  134. self.assertNotIn("Battery_Remaining", dual)
  135. telemetry.feed(bms(mav, 7), now=2)
  136. self.assertNotIn(257, dict(telemetry.tick(now=2)))
  137. telemetry.feed(bms(mav, 1, percent=90), now=6)
  138. expired = dict(telemetry.tick(now=6))[257]
  139. self.assertNotIn("Battery_VoltageLeft", expired)
  140. self.assertEqual(expired["Battery_RemainingRight"], 90)
  141. def test_statustext_immediate_and_packs_without_heartbeat_ignored(self):
  142. telemetry = Telemetry(cfg())
  143. mav = sender()
  144. self.assertEqual(telemetry.feed(bms(mav), now=0), [])
  145. self.assertEqual(telemetry.tick(now=0), [])
  146. telemetry.feed(heartbeat(mav), now=1)
  147. status = mav.statustext_encode(4, "电池低".encode("utf-8")).pack(mav)
  148. self.assertEqual(telemetry.feed(status, now=1),
  149. [(260, {"severity": 4, "msg": "电池低"})])
  150. def test_nonfinite_floats_are_omitted_from_json(self):
  151. telemetry = Telemetry(cfg())
  152. mav = sender()
  153. telemetry.feed(heartbeat(mav), now=0)
  154. telemetry.feed(mav.attitude_encode(
  155. 1000, float("nan"), 0.25, float("inf"), 0, 0, 0).pack(mav), now=0)
  156. telemetry.feed(mav.vfr_hud_encode(
  157. float("nan"), 5, 0, 0, 0, float("inf")).pack(mav), now=0)
  158. report = dict(telemetry.tick(now=0))[258]
  159. self.assertNotIn("Roll", report)
  160. self.assertNotIn("Yaw", report)
  161. self.assertNotIn("AirSpeed", report)
  162. self.assertNotIn("ClimbRate", report)
  163. self.assertAlmostEqual(report["Pitch"], math.degrees(0.25), places=4)
  164. self.assertEqual(report["GroundSpeed"], 5)
  165. json.dumps(report, allow_nan=False)
  166. class BridgeTelemetryTests(unittest.TestCase):
  167. def test_bridge_reports_and_raw_switch(self):
  168. from test_bridge import FakeMqtt, FakeSerial
  169. for raw in (True, False):
  170. with self.subTest(raw=raw):
  171. conf = cfg(publish_raw_mavlink=raw)
  172. mqtt = FakeMqtt()
  173. fake_serial = FakeSerial()
  174. mav = sender()
  175. fake_serial.to_read.append(heartbeat(mav, True) + position(mav) + bms(mav))
  176. bridge = Bridge(conf, mqtt, lambda *args, **kwargs: fake_serial)
  177. bridge.start()
  178. bridge._on_connect(mqtt, None, None, 0, None)
  179. try:
  180. deadline = time.monotonic() + 2
  181. while len(mqtt.published) < (4 if raw else 3) and time.monotonic() < deadline:
  182. time.sleep(0.01)
  183. by_type = {json.loads(payload)["DataType"]: (topic, json.loads(payload)["Data"])
  184. for topic, payload, _, _ in mqtt.published}
  185. self.assertEqual(526 in by_type, raw)
  186. self.assertEqual(by_type[274], (conf.receive_topic, True))
  187. self.assertEqual(by_type[257][0], conf.receive_topic)
  188. self.assertEqual(by_type[258][1]["Latitude"], 31.2304)
  189. self.assertEqual(by_type[258][0], conf.receive_topic)
  190. # Turning off upstream raw frames does not affect #526 downlink.
  191. bridge._on_message(mqtt, None, type("Command", (), {
  192. "topic": conf.obtain_topic,
  193. "retain": False,
  194. "payload": json.dumps({
  195. "DataType": 526, "UAVType": conf.uav_type,
  196. "SystemID": conf.system_id, "TimeStamp": "2026-09-29T00:00:00+08:00",
  197. "Data": "01FF",
  198. }).encode(),
  199. })())
  200. self.assertEqual(fake_serial.written[-1], b"\x01\xff")
  201. finally:
  202. bridge.stop()
  203. def test_serial_reconnect_rebinds_flight_controller(self):
  204. from test_bridge import FakeMqtt, FakeSerial
  205. class DisconnectedSerial(FakeSerial):
  206. def read(self, size):
  207. if self.to_read:
  208. return self.to_read.pop(0)
  209. raise OSError("cable disconnected")
  210. first = DisconnectedSerial()
  211. first.to_read.append(heartbeat(sender(1), True))
  212. second = FakeSerial()
  213. second.to_read.append(heartbeat(sender(2), False))
  214. opened = []
  215. def factory(*args, **kwargs):
  216. opened.append(True)
  217. return first if len(opened) == 1 else second
  218. conf = cfg(publish_raw_mavlink=False, reconnect_seconds=0.01)
  219. mqtt = FakeMqtt()
  220. bridge = Bridge(conf, mqtt, factory)
  221. bridge._on_connect(mqtt, None, None, 0, None)
  222. bridge.start()
  223. try:
  224. deadline = time.monotonic() + 2
  225. while bridge._telemetry.source != (2, 1) and time.monotonic() < deadline:
  226. time.sleep(0.01)
  227. self.assertTrue(first.closed)
  228. self.assertEqual(bridge._telemetry.source, (2, 1))
  229. armed_events = [json.loads(payload)["Data"] for topic, payload, _, _ in mqtt.published
  230. if json.loads(payload)["DataType"] == 274]
  231. self.assertEqual(armed_events, [True, False])
  232. finally:
  233. bridge.stop()
  234. def test_serial_loop_auto_downloads_parameters(self):
  235. from test_bridge import FakeMqtt, FakeSerial
  236. mav = sender()
  237. fake_serial = FakeSerial()
  238. fake_serial.to_read.append(heartbeat(mav, True))
  239. conf = cfg(publish_raw_mavlink=False)
  240. mqtt = FakeMqtt()
  241. bridge = Bridge(conf, mqtt, lambda *args, **kwargs: fake_serial)
  242. bridge._on_connect(mqtt, None, None, 0, None)
  243. bridge.start()
  244. try:
  245. deadline = time.monotonic() + 2
  246. while not fake_serial.written and time.monotonic() < deadline:
  247. time.sleep(0.01)
  248. types = [decode_frame(frame).get_type() for frame in fake_serial.written]
  249. self.assertIn("PARAM_REQUEST_LIST", types)
  250. request = next(decode_frame(frame) for frame in fake_serial.written
  251. if decode_frame(frame).get_type() == "PARAM_REQUEST_LIST")
  252. self.assertEqual((request.target_system, request.target_component), (1, 1))
  253. finally:
  254. bridge.stop()
  255. if __name__ == "__main__":
  256. unittest.main()