test_bridge.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. import json
  2. import os
  3. import threading
  4. import time
  5. import unittest
  6. from types import SimpleNamespace
  7. from unittest.mock import patch
  8. import serial
  9. from motian_bridge import Bridge, Config, decode_command, make_report
  10. from motian_bridge.bridge import decode_hex
  11. def config(**changes):
  12. values = dict(system_id="UAV000001", uav_type=7, serial_port="loop://",
  13. serial_baudrate=57600, mqtt_host="localhost")
  14. values.update(changes)
  15. return Config(**values)
  16. def command(data_type=526, data="FE0600FF", **changes):
  17. value = dict(DataType=data_type, UAVType=7, Data=data,
  18. SystemID="UAV000001", TimeStamp="2026-09-28T12:00:00+08:00")
  19. value.update(changes)
  20. return json.dumps(value).encode()
  21. class FakeMqtt:
  22. def __init__(self):
  23. self.published = []
  24. self.subscriptions = []
  25. self.active = False
  26. def subscribe(self, topic, qos):
  27. self.subscriptions.append((topic, qos))
  28. return (0, 1)
  29. def publish(self, topic, payload, qos, retain):
  30. self.published.append((topic, payload, qos, retain))
  31. return SimpleNamespace(rc=0)
  32. def connect_async(self, *args):
  33. self.active = True
  34. def loop_start(self):
  35. pass
  36. def disconnect(self):
  37. self.active = False
  38. def loop_stop(self):
  39. pass
  40. class FakeSerial:
  41. def __init__(self):
  42. self.written = []
  43. self.closed = False
  44. self.to_read = []
  45. def write(self, data):
  46. self.written.append(data)
  47. return len(data)
  48. def read(self, size):
  49. time.sleep(0.01)
  50. return self.to_read.pop(0) if self.to_read else b""
  51. def close(self):
  52. self.closed = True
  53. class BridgeTests(unittest.TestCase):
  54. def setUp(self):
  55. self.cfg = config()
  56. self.mqtt = FakeMqtt()
  57. self.serial = FakeSerial()
  58. self.bridge = Bridge(self.cfg, self.mqtt, lambda *args, **kwargs: self.serial)
  59. self.bridge._serial = self.serial
  60. self.bridge._on_connect(self.mqtt, None, None, 0, None)
  61. def deliver(self, payload, topic=None, retain=False):
  62. message = SimpleNamespace(topic=topic or self.cfg.obtain_topic,
  63. payload=payload, retain=retain)
  64. self.bridge._on_message(self.mqtt, None, message)
  65. def test_passthrough_bytes_and_exact_topic(self):
  66. self.deliver(command(data="00Feff01"))
  67. self.assertEqual(self.serial.written, [bytes.fromhex("00FEFF01")])
  68. self.assertEqual(self.mqtt.subscriptions, [(self.cfg.obtain_topic, 0)])
  69. def test_invalid_messages_never_write(self):
  70. bad = [b"not-json", b"[]", command(DataType=True),
  71. command(DataType=2049, SystemID="other"),
  72. command(UAVType=8), command(data="F"),
  73. command(data="FG"), command(data=""),
  74. command(data="AA BB"), command(data="AA" * 4097)]
  75. for payload in bad:
  76. self.deliver(payload)
  77. self.deliver(command(), retain=True)
  78. self.deliver(command(), topic="/OBTAIN/other")
  79. self.assertEqual(self.serial.written, [])
  80. def test_no_replay_on_disconnect_and_no_unsupported_action(self):
  81. self.bridge._on_disconnect(self.mqtt, None, None, 1, None)
  82. self.deliver(command())
  83. self.assertEqual(self.serial.written, [])
  84. self.assertFalse(self.bridge.publish_report(526, "AA", passthrough=True))
  85. self.bridge._on_connect(self.mqtt, None, None, 0, None)
  86. self.deliver(command(2054, None))
  87. self.assertEqual(self.serial.written, [])
  88. self.deliver(command())
  89. self.assertEqual(self.serial.written, [bytes.fromhex("FE0600FF")])
  90. def test_extension_handler_and_report(self):
  91. seen = []
  92. def handler(message, bridge):
  93. seen.append(message.data)
  94. bridge.publish_report(270, {"Text": "ok", "Cmd": message.data_type, "Code": 0})
  95. self.bridge.register_handler(2054, handler)
  96. self.deliver(command(2054, None))
  97. self.assertEqual(seen, [None])
  98. self.assertEqual(self.mqtt.published[0][0], self.cfg.receive_topic)
  99. self.assertEqual(self.mqtt.published[0][2:], (0, False))
  100. def test_serial_chunk_upstream_envelope(self):
  101. self.serial.to_read.append(b"\xfe\x00\xff")
  102. thread = threading.Thread(target=self.bridge._serial_loop)
  103. thread.start()
  104. try:
  105. deadline = time.monotonic() + 1
  106. while not self.mqtt.published and time.monotonic() < deadline:
  107. time.sleep(0.01)
  108. self.assertEqual(len(self.mqtt.published), 1)
  109. topic, payload, qos, retain = self.mqtt.published[0]
  110. self.assertEqual((topic, qos, retain), (self.cfg.publish_topic, 0, False))
  111. value = json.loads(payload)
  112. self.assertEqual(value["DataType"], 526)
  113. self.assertEqual(value["Data"], "FE00FF")
  114. self.assertEqual(value["UAVType"], 7)
  115. self.assertEqual(value["SystemID"], "UAV000001")
  116. self.assertIn("+", value["TimeStamp"])
  117. self.assertNotIn("DataType ", value)
  118. finally:
  119. self.bridge._stop.set()
  120. thread.join(1)
  121. class ValidationTests(unittest.TestCase):
  122. def test_envelope_and_hex(self):
  123. decoded = decode_command(command(), config())
  124. self.assertEqual(decoded.data_type, 526)
  125. self.assertEqual(decode_hex(decoded.data, 8), bytes.fromhex("FE0600FF"))
  126. self.assertEqual(json.loads(make_report(config(), 526, "AB"))["Data"], "AB")
  127. def test_config_validation(self):
  128. for changes in ({"system_id": "x/#"}, {"uav_type": -1},
  129. {"max_chunk_bytes": 0}, {"mqtt_password_env": "PASS"},
  130. {"request_params_on_start": 1}, {"param_request_timeout": 0},
  131. {"param_request_max_attempts": 0}):
  132. with self.subTest(changes=changes), self.assertRaises(ValueError):
  133. config(**changes)
  134. def test_password_is_read_from_environment(self):
  135. cfg = config(mqtt_username="user", mqtt_password_env="BRIDGE_TEST_SECRET")
  136. with patch.dict(os.environ, {"BRIDGE_TEST_SECRET": "secret"}):
  137. self.assertEqual(cfg.mqtt_password(), "secret")
  138. with patch.dict(os.environ, {}, clear=True), self.assertRaises(ValueError):
  139. cfg.mqtt_password()
  140. def test_real_pyserial_loopback(self):
  141. cfg = config()
  142. mqtt = FakeMqtt()
  143. bridge = Bridge(cfg, mqtt, serial.serial_for_url)
  144. bridge.start()
  145. bridge._on_connect(mqtt, None, None, 0, None)
  146. try:
  147. deadline = time.monotonic() + 2
  148. while bridge._serial is None and time.monotonic() < deadline:
  149. time.sleep(0.01)
  150. self.assertIsNotNone(bridge._serial)
  151. bridge._on_message(mqtt, None, SimpleNamespace(
  152. topic=cfg.obtain_topic, payload=command(data="00FEFF"), retain=False))
  153. while not mqtt.published and time.monotonic() < deadline:
  154. time.sleep(0.01)
  155. self.assertEqual(len(mqtt.published), 1)
  156. self.assertEqual(json.loads(mqtt.published[0][1])["Data"], "00FEFF")
  157. finally:
  158. bridge.stop()
  159. def test_serial_reconnect_discards_old_bytes(self):
  160. class BrokenSerial(FakeSerial):
  161. def read(self, size):
  162. raise OSError("link lost")
  163. first, second = BrokenSerial(), FakeSerial()
  164. second.to_read.append(b"\x02")
  165. opened = []
  166. def factory(*args, **kwargs):
  167. opened.append(True)
  168. return first if len(opened) == 1 else second
  169. cfg = config(reconnect_seconds=0.01)
  170. mqtt = FakeMqtt()
  171. bridge = Bridge(cfg, mqtt, factory)
  172. bridge.start()
  173. bridge._on_connect(mqtt, None, None, 0, None)
  174. try:
  175. deadline = time.monotonic() + 1
  176. while not mqtt.published and time.monotonic() < deadline:
  177. time.sleep(0.01)
  178. self.assertGreaterEqual(len(opened), 2)
  179. self.assertTrue(first.closed)
  180. self.assertEqual(json.loads(mqtt.published[0][1])["Data"], "02")
  181. finally:
  182. bridge.stop()
  183. if __name__ == "__main__":
  184. unittest.main()