diff --git a/app/main.py b/app/main.py index 911635b..67e8992 100644 --- a/app/main.py +++ b/app/main.py @@ -45,6 +45,40 @@ setup_logging() logger = logging.getLogger(__name__) +def _install_meshcore_serial_junk_prefix_patch(serial_connection_cls=None) -> None: + """Make meshcore serial framing tolerant of leading junk bytes. + + Some radios emit console/debug text on the same UART as the companion + protocol. The current meshcore serial parser searches for the frame start + marker but then incorrectly begins parsing at byte 0 of the chunk instead + of the located marker offset, which can drop valid response frames. + + TODO: Remove this monkeypatch once meshcore_py includes the upstream fix: + https://github.com/meshcore-dev/meshcore_py/pull/67 + """ + if serial_connection_cls is None: + from meshcore.serial_cx import SerialConnection as serial_connection_cls + + original_handle_rx = serial_connection_cls.handle_rx + if getattr(original_handle_rx, "_rtmesh_junk_prefix_patch", False): + return + + def patched_handle_rx(self, data: bytearray): + if len(self.header) == 0: + idx = data.find(b"\x3e") + if idx < 0: + return + if idx > 0: + data = data[idx:] + return original_handle_rx(self, data) + + patched_handle_rx._rtmesh_junk_prefix_patch = True + serial_connection_cls.handle_rx = patched_handle_rx + + +_install_meshcore_serial_junk_prefix_patch() + + async def _startup_radio_connect_and_setup() -> None: """Connect/setup the radio in the background so HTTP serving can start immediately.""" try: diff --git a/tests/test_main_startup.py b/tests/test_main_startup.py index 0bb5cb7..00655fc 100644 --- a/tests/test_main_startup.py +++ b/tests/test_main_startup.py @@ -5,10 +5,84 @@ from unittest.mock import AsyncMock, patch import pytest -from app.main import app, lifespan +from app.main import _install_meshcore_serial_junk_prefix_patch, app, lifespan class TestStartupLifespan: + @pytest.mark.asyncio + async def test_meshcore_serial_patch_discards_leading_junk_before_frame(self): + class RecordingReader: + def __init__(self): + self.frames = [] + + async def handle_rx(self, data): + self.frames.append(bytes(data)) + + class FakeSerialConnection: + def __init__(self): + self.header = b"" + self.reader = None + self.frame_expected_size = 0 + self.inframe = b"" + + def set_reader(self, reader): + self.reader = reader + + def handle_rx(self, data: bytearray): + if len(self.header) == 0: + idx = data.find(b"\x3e") + if idx < 0: + return + self.header = data[0:1] + data = data[1:] + + if len(self.header) < 3: + while len(self.header) < 3 and len(data) > 0: + self.header = self.header + data[0:1] + data = data[1:] + if len(self.header) < 3: + return + + self.frame_expected_size = int.from_bytes( + self.header[1:], "little", signed=False + ) + if self.frame_expected_size > 300: + self.header = b"" + self.inframe = b"" + self.frame_expected_size = 0 + if len(data) > 0: + self.handle_rx(data) + return + + upbound = self.frame_expected_size - len(self.inframe) + if len(data) < upbound: + self.inframe = self.inframe + data + return + + self.inframe = self.inframe + data[0:upbound] + data = data[upbound:] + if self.reader is not None: + asyncio.create_task(self.reader.handle_rx(self.inframe)) + self.inframe = b"" + self.header = b"" + self.frame_expected_size = 0 + if len(data) > 0: + self.handle_rx(data) + + _install_meshcore_serial_junk_prefix_patch(FakeSerialConnection) + + conn = FakeSerialConnection() + reader = RecordingReader() + conn.set_reader(reader) + + payload = b"\x00\x01\x02\x53" + frame = b"\x3e" + len(payload).to_bytes(2, "little") + payload + + conn.handle_rx(b"junk bytes\r\n" + frame) + await asyncio.sleep(0) + + assert reader.frames == [payload] + @pytest.mark.asyncio async def test_lifespan_does_not_wait_for_radio_setup(self): """HTTP serving should start before post-connect setup finishes."""