diff --git a/docs/MESHCORE.md b/docs/MESHCORE.md index 67a68bd..bb6ca00 100644 --- a/docs/MESHCORE.md +++ b/docs/MESHCORE.md @@ -6,6 +6,8 @@ **Phase 1 upload:** when `MESHCORE_UPLOAD_ENABLED=true` and `STORAGE_API_*` are set, selected events upload to `POST /api/meshcore/feeders/{prefix}/packets/ingest/` (12-hex pubkey prefix from `SELF_INFO`). +On first connect to the device, the bot sends one **flood-routed advert** (`send_advert(flood=True)`) so the feeder appears on the mesh without waiting for firmware timing. Periodic API-driven intervals are planned ([#116](https://github.com/pskillen/meshflow-bot/issues/116)). + ## Transports | Transport | Env vars | Notes | diff --git a/src/meshcore/radio.py b/src/meshcore/radio.py index 285aef6..30b7579 100644 --- a/src/meshcore/radio.py +++ b/src/meshcore/radio.py @@ -236,6 +236,7 @@ async def _run_session(self) -> None: if not self._connected_once: self._connected_once = True + self.schedule_initial_flood_advert() if self._handlers.on_connection_established: call_safely( "meshcore.on_connection_established", @@ -276,6 +277,34 @@ def dispatch_meshcore_event_for_tests(self, event: Event) -> None: """Synchronous hook for unit tests (same path as async subscriber).""" self._dispatch_meshcore_event(event) + def schedule_initial_flood_advert(self) -> None: + """Send one flood-routed advert after first connect (stop-gap until API-driven interval).""" + loop = self._loop + if loop is None or not loop.is_running(): + logger.warning( + "MeshCore initial flood advert not scheduled: event loop not running" + ) + return + + async def _task() -> None: + mc = self._meshcore + if mc is None or not mc.is_connected: + return + try: + result = await mc.commands.send_advert(flood=True) + if result.type == EventType.ERROR: + logger.warning( + "MeshCore send_advert(flood=True) failed: %s", + result.payload, + ) + else: + logger.info("MeshCore sent initial flood advert") + except Exception: + self._error_counter.increment("meshcore.send_initial_flood_advert") + logger.exception("MeshCore initial flood advert failed") + + asyncio.create_task(_task()) + def schedule_channel_sync(self, storage_apis: list) -> None: """Schedule channel sync on the radio loop (must run on that loop's thread).""" if not storage_apis: diff --git a/test/meshcore/test_initial_flood_advert.py b/test/meshcore/test_initial_flood_advert.py new file mode 100644 index 0000000..6342be9 --- /dev/null +++ b/test/meshcore/test_initial_flood_advert.py @@ -0,0 +1,54 @@ +"""Tests for one-shot flood advert on MeshCore connect.""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +from meshcore.events import Event, EventType + +from src.meshcore.radio import MeshCoreRadio + + +def test_schedule_initial_flood_advert_sends_on_loop() -> None: + async def _runner() -> None: + radio = MeshCoreRadio.__new__(MeshCoreRadio) + loop = asyncio.get_running_loop() + radio._loop = loop # noqa: SLF001 + radio._error_counter = MagicMock() + + mc = MagicMock() + mc.is_connected = True + mc.commands.send_advert = AsyncMock( + return_value=Event(EventType.OK, {}, {}) + ) + radio._meshcore = mc + + radio.schedule_initial_flood_advert() + await asyncio.sleep(0.05) + + mc.commands.send_advert.assert_awaited_once_with(flood=True) + + asyncio.run(_runner()) + + +def test_schedule_initial_flood_advert_logs_error_event() -> None: + async def _runner() -> None: + radio = MeshCoreRadio.__new__(MeshCoreRadio) + loop = asyncio.get_running_loop() + radio._loop = loop # noqa: SLF001 + radio._error_counter = MagicMock() + + mc = MagicMock() + mc.is_connected = True + mc.commands.send_advert = AsyncMock( + return_value=Event(EventType.ERROR, "device busy", {}) + ) + radio._meshcore = mc + + radio.schedule_initial_flood_advert() + await asyncio.sleep(0.05) + + mc.commands.send_advert.assert_awaited_once_with(flood=True) + + asyncio.run(_runner())