meshai/work/tests/test_meshcore_telemetry.py
malice a7b7f5a6a4
feat(transport): per-radio serialized+paced outbound send queue (#93)
* feat(transport): per-radio serialized+paced outbound send queue

Prevents simultaneous LoRa transmissions when N events arrive at once.

## Mechanism

Two `RadioSendQueue` instances (one MT, one MC), each a FIFO asyncio.Queue
with a long-running drain task.  The MT queue drains on the main asyncio
loop; the MC queue drains on MeshCore's dedicated event-loop thread.

- MT sends: `run_in_executor` offloads the blocking `sendText` call;
  queue started in `set_message_callback`, cancelled in `disconnect`.
- MC sends: drain loop runs pure-async MC lib coroutines directly on the
  MC loop (no `_run_coro` deadlock); cross-loop callers bridge via
  `concurrent.futures.Future` + `asyncio.wrap_future`.
- Pacing: `await asyncio.sleep(pacing_seconds)` between items; read live
  from config per iteration; floor clamped to 0.25 s.
- Config knobs: `meshtastic_send_pacing_seconds` (default 2.0) and
  `meshcore_send_pacing_seconds` (default 2.0) on `ConnectionConfig`.

## Send sites rerouted

All callers now `await connector.send_message_async(...)`:
- `notifications/channels.py` — MeshBroadcast/MeshCoreBroadcast/MeshDM/
  MeshCoreDM deliver(), test_connection(), deliver_test()
- `responder.py` — DM replies in send_response()
- `transport/meshcore_transport.py` — periodic_advert_loop, telemetry
  poll loop, send_advert() → send_advert_async(), req_telemetry()
  → req_telemetry_async() (all queue-routed from main loop)
- `dashboard/api/mesh_send_routes.py` — test-send, advert, telemetry poll

## Audit accuracy

`deliver()` now returns the actual bool from the radio send (not
optimistic True), so `mesh_broadcasts_out` reflects the real result.

## Tests

17 new tests in tests/test_send_queue.py covering FIFO ordering, no drops,
pacing gap, pacing floor enforcement, event-loop non-blocking, serialization,
lifecycle, MT fallback, config round-trip.  Existing test stubs updated to
wire `send_message_async = AsyncMock(side_effect=send_message)` so prior
call_count / call_args assertions remain valid without changes.

Full suite: 2135 passed, 17 pre-existing failures (unchanged), 0 new regressions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(send-queue): resolve MC telemetry self-deadlock + resolve pending futures on teardown/reconnect; composite MC-channel kwarg; audit no-op false

BLOCKER 1 — req_telemetry_async self-deadlock (meshcore_transport.py):
_req_telemetry_async was calling _enqueue_mc_loop_send inside itself;
when _telem_job_outer ran inside the drain it nested another enqueue+await
on the same single-threaded drain — permanent deadlock on first telemetry poll.
Fix: _req_telemetry_async is now fully inline (no _enqueue_mc_loop_send).
_telemetry_poll_loop wraps its call in _enqueue_mc_loop_send for serialization.
req_telemetry_async's outer job calls _req_telemetry_async inline (safe).

BLOCKER 2 — pending futures abandoned on teardown/reconnect:
RadioSendQueue.stop() only cancelled the drain task; queue-sitting items had
their concurrent.futures.Futures left unresolved, causing wrap_future() callers
to hang indefinitely. Fix: stop() drains the remaining queue with get_nowait()
and cancels every pending cfut. _cancel_mc_queue() schedules the same drain-
and-cancel via call_soon_threadsafe. _start_mc_queue() cancels old drain task
and drains old queue cfuts before arming the new queue (reconnect path).
connector.disconnect() now .result(timeout=5) on stop() instead of fire-and-forget.

SHOULD-FIX 3 — composite passes MC channel as wrong kwarg (composite_transport.py):
_broadcast_async no-hint loop was calling send_message_async(channel=child_channel)
for the meshcore child; should be meshcore_channel=child_channel. Silent drop fixed.

NIT 5 — false success on zero-channel MC send (meshcore_transport.py):
send_message_async returned True when meshcore_channel is None (nothing sent).
Now returns False so audit does not record a success for a no-op.

NIT 7 — config comment contradiction (config.py):
meshtastic_send_pacing_seconds comment said "0 disables the floor" while
simultaneously stating "still floored at 0.25". Removed the contradiction.

Regression tests (tests/test_send_queue.py — 3 new, all in TestDeadlockRegression):
- test_telemetry_queue_no_deadlock: drives req_telemetry_async through a real
  _mc_send_queue with fake MC commands; times out on pre-fix code (deadlock).
- test_teardown_resolves_pending_futures: enqueues slow+fast jobs, stops mid-drain,
  asserts every task resolves promptly; hangs on pre-fix code.
- test_reconnect_resolves_old_futures: calls _start_mc_queue twice, asserts old
  cfuts are cancelled; pre-fix leaves them unresolved.

All 17 pre-existing send-queue tests still pass (20 total now).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 09:38:08 -06:00

497 lines
18 KiB
Python

"""Tests for MeshCore telemetry auto-poll (backend).
Fully mocked — no real socket, no meshcore lib required. A minimal fake
``meshcore`` module is injected into sys.modules before the production code's
lazy import triggers, mirroring test_meshcore_transport.py.
"""
import asyncio
import sys
import threading
import types
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
# ---------------------------------------------------------------------------
# Fake meshcore module (registered before production imports)
# ---------------------------------------------------------------------------
def _build_fake_meshcore():
mod = types.ModuleType("meshcore")
class EventType:
CONTACT_MSG_RECV = "CONTACT_MSG_RECV"
CHANNEL_MSG_RECV = "CHANNEL_MSG_RECV"
DISCONNECTED = "DISCONNECTED"
CONNECTED = "CONNECTED"
# Added in main alongside ACK-wait delivery confirmation — required by
# _setup_subscriptions(); fake must include them or connect() blows up.
ACK = "acknowledgement"
NEW_CONTACT = "new_contact"
mod.EventType = EventType
class _FakeMeshCore:
self_info = {"public_key": "aabbccdd1122", "name": "FakeNode"}
contacts = {}
async def start_auto_message_fetching(self):
pass
async def stop_auto_message_fetching(self):
pass
async def disconnect(self):
pass
def subscribe(self, event_type, callback):
pass
def get_contact_by_key_prefix(self, prefix):
return None
@classmethod
async def create_tcp(cls, host, port,
auto_reconnect=True, max_reconnect_attempts=5):
return cls()
class commands:
@staticmethod
async def req_telemetry_sync(contact, timeout=0, min_timeout=0):
return None
mod.MeshCore = _FakeMeshCore
return mod
sys.modules.setdefault("meshcore", _build_fake_meshcore())
# ---------------------------------------------------------------------------
# Production imports
# ---------------------------------------------------------------------------
from meshai.config import ( # noqa: E402
ConnectionConfig, _dataclass_to_dict, _dict_to_dataclass,
)
from meshai.transport.meshcore_transport import ( # noqa: E402
MeshCoreTransport,
_TELEMETRY_MAX_FAILURES,
_TELEMETRY_MIN_INTERVAL_SECONDS,
)
from meshai.dashboard.api.mesh_send_routes import router # noqa: E402
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _mc_config(**overrides):
cfg = ConnectionConfig(meshcore_host="127.0.0.1", meshcore_port=5050)
for k, v in overrides.items():
setattr(cfg, k, v)
return cfg
def _transport_with_mock_mc(mc_overrides=None, **cfg_overrides):
"""MeshCoreTransport with a MagicMock _mc + a live dedicated loop thread."""
cfg = _mc_config(**cfg_overrides)
t = MeshCoreTransport(cfg)
mc = MagicMock()
mc.get_contact_by_key_prefix.return_value = None
mc.get_contact_by_name.return_value = None
if mc_overrides:
for k, v in mc_overrides.items():
setattr(mc, k, v)
t._mc = mc
t._connected = True
loop = asyncio.new_event_loop()
t._loop = loop
thread = threading.Thread(target=loop.run_forever, daemon=True)
thread.start()
t._loop_thread = thread
return t, mc, loop
def _cleanup(t):
try:
if t._loop and t._loop.is_running():
t._loop.call_soon_threadsafe(t._loop.stop)
if t._loop_thread and t._loop_thread.is_alive():
t._loop_thread.join(timeout=2.0)
except Exception:
pass
# A sample telemetry lpp list: voltage, temperature, humidity, battery %, and
# an unknown id (200) that must fall through to lpp_200.
_SAMPLE_LPP = [
{"channel": 0, "type": 116, "value": 3.98},
{"channel": 1, "type": 103, "value": 21.5},
{"channel": 2, "type": 104, "value": 44},
{"channel": 3, "type": 120, "value": 87},
{"channel": 4, "type": 200, "value": 999},
]
# ---------------------------------------------------------------------------
# 1. _decode_lpp
# ---------------------------------------------------------------------------
class TestDecodeLpp:
def test_maps_known_ids_and_preserves_raw(self):
data = MeshCoreTransport._decode_lpp(_SAMPLE_LPP)
assert data["voltage"] == 3.98
assert data["temperature"] == 21.5
assert data["humidity"] == 44
assert data["battery_pct"] == 87
# Unknown id → lpp_<id>
assert data["lpp_200"] == 999
# raw is always the original list
assert data["raw"] == _SAMPLE_LPP
def test_empty_list_yields_only_raw(self):
data = MeshCoreTransport._decode_lpp([])
assert data == {"raw": []}
def test_none_yields_raw_none(self):
data = MeshCoreTransport._decode_lpp(None)
assert data["raw"] is None
# ---------------------------------------------------------------------------
# 2. req_telemetry (sync wrapper, bridged)
# ---------------------------------------------------------------------------
class TestReqTelemetry:
def test_returns_decoded_dict_on_lpp(self):
t, mc, _ = _transport_with_mock_mc()
try:
mc.get_contact_by_key_prefix.return_value = {"adv_name": "Sensor"}
mc.commands.req_telemetry_sync = AsyncMock(return_value=_SAMPLE_LPP)
data = t.req_telemetry("aabbcc")
assert data is not None
assert data["voltage"] == 3.98
assert data["temperature"] == 21.5
mc.commands.req_telemetry_sync.assert_awaited_once()
# min_timeout is passed so a node gets a reasonable window.
_, kwargs = mc.commands.req_telemetry_sync.call_args
assert kwargs.get("min_timeout") == 5
finally:
_cleanup(t)
def test_returns_none_on_timeout(self):
t, mc, _ = _transport_with_mock_mc()
try:
mc.get_contact_by_key_prefix.return_value = {"adv_name": "Sensor"}
mc.commands.req_telemetry_sync = AsyncMock(return_value=None)
assert t.req_telemetry("aabbcc") is None
finally:
_cleanup(t)
def test_returns_none_when_unresolved(self):
t, mc, _ = _transport_with_mock_mc()
try:
mc.get_contact_by_key_prefix.return_value = None
mc.get_contact_by_name.return_value = None
mc.commands.req_telemetry_sync = AsyncMock(return_value=_SAMPLE_LPP)
assert t.req_telemetry("ghost") is None
mc.commands.req_telemetry_sync.assert_not_awaited()
finally:
_cleanup(t)
def test_resolves_by_name_when_prefix_misses(self):
t, mc, _ = _transport_with_mock_mc()
try:
mc.get_contact_by_key_prefix.return_value = None
mc.get_contact_by_name.return_value = {"adv_name": "ByName"}
mc.commands.req_telemetry_sync = AsyncMock(return_value=_SAMPLE_LPP)
data = t.req_telemetry("ByName")
assert data is not None and data["humidity"] == 44
finally:
_cleanup(t)
def test_returns_none_when_not_connected(self):
t = MeshCoreTransport(_mc_config()) # _mc None, no loop
assert t.req_telemetry("aabbcc") is None
# ---------------------------------------------------------------------------
# 3. Poller bookkeeping — via _req_telemetry_async on the loop
# ---------------------------------------------------------------------------
class TestPollerBookkeeping:
def _run(self, t, coro):
return t._run_coro(coro, timeout=5.0)
def test_caches_reading_for_contact(self):
t, mc, _ = _transport_with_mock_mc()
try:
mc.get_contact_by_key_prefix.return_value = {"adv_name": "Sensor"}
mc.commands.req_telemetry_sync = AsyncMock(return_value=_SAMPLE_LPP)
self._run(t, t._req_telemetry_async("nodeA"))
cache = {e["contact"]: e for e in t.get_telemetry_cache()}
assert "nodeA" in cache
assert cache["nodeA"]["available"] is True
assert cache["nodeA"]["data"]["voltage"] == 3.98
assert cache["nodeA"]["polled_at"] is not None
finally:
_cleanup(t)
def test_marks_unavailable_after_max_failures(self):
t, mc, _ = _transport_with_mock_mc()
try:
mc.get_contact_by_key_prefix.return_value = {"adv_name": "Sensor"}
mc.commands.req_telemetry_sync = AsyncMock(return_value=None)
for _ in range(_TELEMETRY_MAX_FAILURES):
self._run(t, t._req_telemetry_async("nodeB"))
cache = {e["contact"]: e for e in t.get_telemetry_cache()}
assert cache["nodeB"]["available"] is False
assert t._telemetry_failures["nodeB"] >= _TELEMETRY_MAX_FAILURES
finally:
_cleanup(t)
def test_stays_available_before_max_failures(self):
t, mc, _ = _transport_with_mock_mc()
try:
mc.get_contact_by_key_prefix.return_value = {"adv_name": "Sensor"}
mc.commands.req_telemetry_sync = AsyncMock(return_value=None)
# One miss (< max) — still available.
self._run(t, t._req_telemetry_async("nodeC"))
cache = {e["contact"]: e for e in t.get_telemetry_cache()}
assert cache["nodeC"]["available"] is True
finally:
_cleanup(t)
def test_success_after_failures_flips_back_available(self):
t, mc, _ = _transport_with_mock_mc()
try:
mc.get_contact_by_key_prefix.return_value = {"adv_name": "Sensor"}
# Drive it unavailable.
mc.commands.req_telemetry_sync = AsyncMock(return_value=None)
for _ in range(_TELEMETRY_MAX_FAILURES):
self._run(t, t._req_telemetry_async("nodeD"))
cache = {e["contact"]: e for e in t.get_telemetry_cache()}
assert cache["nodeD"]["available"] is False
# A later success un-sticks it and resets the counter.
mc.commands.req_telemetry_sync = AsyncMock(return_value=_SAMPLE_LPP)
self._run(t, t._req_telemetry_async("nodeD"))
cache = {e["contact"]: e for e in t.get_telemetry_cache()}
assert cache["nodeD"]["available"] is True
assert cache["nodeD"]["data"]["voltage"] == 3.98
assert t._telemetry_failures["nodeD"] == 0
finally:
_cleanup(t)
def test_manual_poll_unsticks_unavailable(self):
"""The sync req_telemetry wrapper shares bookkeeping: a manual poll
after failures flips availability back on."""
t, mc, _ = _transport_with_mock_mc()
try:
mc.get_contact_by_key_prefix.return_value = {"adv_name": "Sensor"}
mc.commands.req_telemetry_sync = AsyncMock(return_value=None)
for _ in range(_TELEMETRY_MAX_FAILURES):
t.req_telemetry("nodeE")
cache = {e["contact"]: e for e in t.get_telemetry_cache()}
assert cache["nodeE"]["available"] is False
mc.commands.req_telemetry_sync = AsyncMock(return_value=_SAMPLE_LPP)
assert t.req_telemetry("nodeE") is not None
cache = {e["contact"]: e for e in t.get_telemetry_cache()}
assert cache["nodeE"]["available"] is True
finally:
_cleanup(t)
# ---------------------------------------------------------------------------
# 4. Effective interval (min-floor airtime guard)
# ---------------------------------------------------------------------------
class TestEffectiveInterval:
def test_below_floor_clamped_up(self):
t = MeshCoreTransport(_mc_config(meshcore_telemetry_interval_seconds=60))
assert t._effective_telemetry_interval() == _TELEMETRY_MIN_INTERVAL_SECONDS
assert t._effective_telemetry_interval() == 300
def test_above_floor_preserved(self):
t = MeshCoreTransport(_mc_config(meshcore_telemetry_interval_seconds=1800))
assert t._effective_telemetry_interval() == 1800
def test_zero_disables(self):
t = MeshCoreTransport(_mc_config(meshcore_telemetry_interval_seconds=0))
assert t._effective_telemetry_interval() is None
# ---------------------------------------------------------------------------
# 5. Poller scheduler lifecycle
# ---------------------------------------------------------------------------
class TestPollerScheduler:
def test_task_armed_when_interval_nonzero(self):
import time
cfg = _mc_config(meshcore_telemetry_interval_seconds=1800,
meshcore_advert_interval_seconds=0)
t = MeshCoreTransport(cfg)
try:
t.connect()
time.sleep(0.1)
assert t._telemetry_task is not None
finally:
t.disconnect()
def test_task_not_armed_when_interval_zero(self):
import time
cfg = _mc_config(meshcore_telemetry_interval_seconds=0,
meshcore_advert_interval_seconds=0)
t = MeshCoreTransport(cfg)
try:
t.connect()
time.sleep(0.1)
assert t._telemetry_task is None
finally:
t.disconnect()
def test_task_cleared_after_disconnect(self):
import time
cfg = _mc_config(meshcore_telemetry_interval_seconds=1800,
meshcore_advert_interval_seconds=0)
t = MeshCoreTransport(cfg)
t.connect()
time.sleep(0.1)
assert t._telemetry_task is not None
t.disconnect()
assert t._telemetry_task is None
# ---------------------------------------------------------------------------
# 6. Dashboard endpoints
# ---------------------------------------------------------------------------
def _child(transport_name, connected=True):
c = MagicMock()
c.transport_name = transport_name
c.connected = connected
# The dashboard route now calls req_telemetry_async; wire it to delegate to
# the sync mock so existing test setup (c.req_telemetry.return_value = ...)
# and assertions are unaffected.
c.req_telemetry_async = AsyncMock(side_effect=lambda cid: c.req_telemetry(cid))
return c
def _composite(children):
connector = MagicMock()
connector.transport_name = None
connector.children = list(children)
return connector
def _client(connector):
app = FastAPI()
app.include_router(router, prefix="/api")
app.state.connector = connector
return TestClient(app)
class TestTelemetryEndpoints:
def test_get_active_returns_entries(self):
mc = _child("meshcore", connected=True)
mc.get_telemetry_cache.return_value = [
{"contact": "nodeA", "data": {"voltage": 3.98}, "polled_at": "x", "available": True}
]
client = _client(_composite([mc]))
r = client.get("/api/meshcore/telemetry")
assert r.status_code == 200
body = r.json()
assert body["active"] is True
assert body["entries"][0]["contact"] == "nodeA"
def test_get_inactive_when_not_connected(self):
mc = _child("meshcore", connected=False)
client = _client(_composite([mc]))
r = client.get("/api/meshcore/telemetry")
assert r.json() == {"active": False, "entries": []}
def test_get_inactive_when_no_meshcore(self):
mt = _child("meshtastic", connected=True)
client = _client(_composite([mt]))
r = client.get("/api/meshcore/telemetry")
assert r.json() == {"active": False, "entries": []}
def test_poll_available(self):
mc = _child("meshcore", connected=True)
mc.req_telemetry.return_value = {"voltage": 3.98, "raw": []}
client = _client(_composite([mc]))
r = client.post("/api/meshcore/telemetry/poll", json={"contact": "nodeA"})
assert r.status_code == 200
body = r.json()
assert body["available"] is True
assert body["contact"] == "nodeA"
assert body["data"]["voltage"] == 3.98
def test_poll_no_response(self):
mc = _child("meshcore", connected=True)
mc.req_telemetry.return_value = None
client = _client(_composite([mc]))
r = client.post("/api/meshcore/telemetry/poll", json={"contact": "nodeA"})
body = r.json()
assert body["available"] is False
assert body["detail"] == "No telemetry response"
def test_poll_missing_contact(self):
mc = _child("meshcore", connected=True)
client = _client(_composite([mc]))
r = client.post("/api/meshcore/telemetry/poll", json={})
body = r.json()
assert body["available"] is False
assert "Missing" in body["detail"]
def test_poll_not_connected(self):
mc = _child("meshcore", connected=False)
client = _client(_composite([mc]))
r = client.post("/api/meshcore/telemetry/poll", json={"contact": "nodeA"})
body = r.json()
assert body["available"] is False
assert body["detail"] == "MeshCore not connected"
# ---------------------------------------------------------------------------
# 7. Config round-trip
# ---------------------------------------------------------------------------
class TestConfigRoundTrip:
def test_defaults(self):
cfg = ConnectionConfig()
assert cfg.meshcore_telemetry_contacts == []
assert cfg.meshcore_telemetry_interval_seconds == 1800
def test_construct_with_values(self):
cfg = ConnectionConfig(
meshcore_telemetry_contacts=["abc"],
meshcore_telemetry_interval_seconds=900,
)
assert cfg.meshcore_telemetry_contacts == ["abc"]
assert cfg.meshcore_telemetry_interval_seconds == 900
def test_independent_default_lists(self):
a = ConnectionConfig()
b = ConnectionConfig()
a.meshcore_telemetry_contacts.append("x")
assert b.meshcore_telemetry_contacts == []
def test_yaml_round_trip(self):
cfg = ConnectionConfig(
meshcore_telemetry_contacts=["n1", "n2"],
meshcore_telemetry_interval_seconds=600,
)
data = _dataclass_to_dict(cfg)
assert data["meshcore_telemetry_contacts"] == ["n1", "n2"]
assert data["meshcore_telemetry_interval_seconds"] == 600
cfg2 = _dict_to_dataclass(ConnectionConfig, data)
assert cfg2.meshcore_telemetry_contacts == ["n1", "n2"]
assert cfg2.meshcore_telemetry_interval_seconds == 600