test_telemetry.py 11 KB

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