Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/MESHCORE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
29 changes: 29 additions & 0 deletions src/meshcore/radio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand Down
54 changes: 54 additions & 0 deletions test/meshcore/test_initial_flood_advert.py
Original file line number Diff line number Diff line change
@@ -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())