2026-07-03 01:06:31 -06:00
|
|
|
"""API tests for the dashboard 'send test message' routes.
|
|
|
|
|
|
|
|
|
|
Uses a bare FastAPI() + TestClient with a hand-seeded ``app.state.connector``
|
|
|
|
|
(MagicMock-based fakes). The connector fakes mimic a CompositeTransport:
|
|
|
|
|
``transport_name=None`` and an explicit iterable ``children`` list.
|
|
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
feat(meshcore): report the true connection + add roster/channel management
self_info() reported host/port straight from config regardless of
conn_type, so a serial companion still advertised whatever stale
meshcore_host sat in the config — the API named a device meshai was not
talking to, which is enough to send an investigation to the wrong radio.
Connection details now come from one _connection_descriptor() shared with
connect(), so the log line and the API can't drift; only the live
conn_type's fields are populated and the rest are null.
meshai's device view is otherwise built once at connect and never re-read
— contacts via ensure_contacts(), channels via _enumerate_channels(). The
lib's contact handler only ever merges (meshcore.py::_update_contacts), so
a cached roster can never shrink, and a channel provisioned on the radio
stays invisible until the process restarts. There was no refetch path at
all. Adds an explicit resync that re-reads BOTH halves: a FULL
get_contacts(lastmod=0) reconciled with replace semantics (absent contacts
are dropped) plus a channel re-enumeration, each reporting what changed.
Also adds a preventive route-health check: every region_routes cell whose
MeshCore target cannot be resolved against the live roster/channel table
is surfaced, since such a send fails silently. Room targets are matched by
pubkey prefix, exactly as the dispatcher resolves them, so a picker-stored
prefix is not misreported as dangling. Same-name/different-pubkey roster
entries are flagged too — a name alone cannot identify a contact, which is
the trap behind a room rebuilt under a new keypair.
Backend:
- meshcore_roster.py: pure reconcile_contacts / check_route_health /
find_name_collisions (no device I/O — unit-testable without a radio)
- transport: _connection_descriptor, resync, refresh_contacts,
remove_contact, import_contact, export_roster, contacts_synced_at;
auto_update_contacts enabled (configurable — it costs one incremental
fetch per advert heard, which is real chatter on a dense mesh)
- API: POST contacts/refresh, DELETE contacts/{pubkey}, GET
contacts/export, POST contacts/import, GET route-health
Frontend (existing Contacts & Companion page — no new page or nav entry):
- dangling-route + name-collision banners; resync/export/add-contact
toolbar with last-synced and the added/removed counts; staleness badges;
search, filters and sortable columns; per-contact delete behind a
confirm; Companion tab shows the real transport + target.
A full pubkey is required to delete or add: the lib resolves by prefix,
and a prefix could silently hit the wrong node.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 03:50:36 +00:00
|
|
|
from types import SimpleNamespace
|
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
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
2026-07-03 01:06:31 -06:00
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from fastapi import FastAPI
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
|
|
|
|
from meshai.dashboard.api.mesh_send_routes import router
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _child(transport_name, connected=True, known=None):
|
|
|
|
|
"""Build a fake child transport (meshtastic/meshcore)."""
|
|
|
|
|
c = MagicMock()
|
|
|
|
|
c.transport_name = transport_name
|
|
|
|
|
c.connected = connected
|
|
|
|
|
if known is not None:
|
|
|
|
|
c.known_channels.return_value = list(known)
|
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
|
|
|
# Wire async variants so routes can await them; side_effect preserves the
|
|
|
|
|
# sync mock's return_value and call recording for existing assertions.
|
|
|
|
|
c.send_advert_async = AsyncMock(side_effect=lambda: c.send_advert())
|
|
|
|
|
c.req_telemetry_async = AsyncMock(side_effect=lambda cid: c.req_telemetry(cid))
|
2026-07-03 01:06:31 -06:00
|
|
|
return c
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _composite(children, send_result=True):
|
|
|
|
|
"""Build a fake CompositeTransport connector wrapping *children*.
|
|
|
|
|
|
|
|
|
|
A bare MagicMock's auto-attrs are truthy and ``children`` is not
|
|
|
|
|
iterable, so set both explicitly.
|
|
|
|
|
"""
|
|
|
|
|
connector = MagicMock()
|
|
|
|
|
connector.transport_name = None
|
|
|
|
|
connector.children = list(children)
|
|
|
|
|
connector.send_message.return_value = send_result
|
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
|
|
|
# Wire the async variant — routes now call send_message_async; side_effect
|
|
|
|
|
# delegates to the sync mock so existing call_args assertions still pass.
|
|
|
|
|
connector.send_message_async = AsyncMock(
|
|
|
|
|
side_effect=lambda *a, **kw: connector.send_message(*a, **kw)
|
|
|
|
|
)
|
2026-07-03 01:06:31 -06:00
|
|
|
return connector
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _client(connector):
|
|
|
|
|
app = FastAPI()
|
|
|
|
|
app.include_router(router, prefix="/api")
|
|
|
|
|
app.state.connector = connector
|
|
|
|
|
return TestClient(app)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# POST /api/mesh/test-send — meshcore
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_meshcore_success():
|
|
|
|
|
mc = _child("meshcore", connected=True, known=["aida", "emergency"])
|
|
|
|
|
connector = _composite([mc], send_result=True)
|
|
|
|
|
client = _client(connector)
|
|
|
|
|
|
|
|
|
|
r = client.post("/api/mesh/test-send", json={"transport": "meshcore", "channel": "aida"})
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
body = r.json()
|
|
|
|
|
assert body["sent"] is True
|
|
|
|
|
assert "aida" in body["detail"]
|
|
|
|
|
|
|
|
|
|
connector.send_message.assert_called_once()
|
|
|
|
|
_, kwargs = connector.send_message.call_args
|
|
|
|
|
assert kwargs["meshcore_channel"] == "aida"
|
|
|
|
|
assert kwargs["transport"] == "meshcore"
|
|
|
|
|
assert kwargs["destination"] is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_meshcore_unknown_channel():
|
|
|
|
|
mc = _child("meshcore", connected=True, known=["aida"])
|
|
|
|
|
connector = _composite([mc], send_result=False)
|
|
|
|
|
client = _client(connector)
|
|
|
|
|
|
|
|
|
|
r = client.post("/api/mesh/test-send", json={"transport": "meshcore", "channel": "ghost"})
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
body = r.json()
|
|
|
|
|
assert body["sent"] is False
|
|
|
|
|
assert "not on companion" in body["detail"]
|
|
|
|
|
assert "aida" in body["detail"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_meshcore_inactive():
|
|
|
|
|
mt = _child("meshtastic", connected=True)
|
|
|
|
|
connector = _composite([mt])
|
|
|
|
|
client = _client(connector)
|
|
|
|
|
|
|
|
|
|
r = client.post("/api/mesh/test-send", json={"transport": "meshcore", "channel": "aida"})
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
body = r.json()
|
|
|
|
|
assert body["sent"] is False
|
|
|
|
|
assert body["detail"] == "meshcore not connected"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# POST /api/mesh/test-send — meshtastic
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_meshtastic_success():
|
|
|
|
|
mt = _child("meshtastic", connected=True)
|
|
|
|
|
connector = _composite([mt], send_result=True)
|
|
|
|
|
client = _client(connector)
|
|
|
|
|
|
|
|
|
|
r = client.post("/api/mesh/test-send", json={"transport": "meshtastic", "channel": 0})
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
body = r.json()
|
|
|
|
|
assert body["sent"] is True
|
|
|
|
|
|
|
|
|
|
connector.send_message.assert_called_once()
|
|
|
|
|
_, kwargs = connector.send_message.call_args
|
|
|
|
|
assert kwargs["channel"] == 0
|
|
|
|
|
assert kwargs["transport"] == "meshtastic"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# Default text
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_default_text_when_omitted():
|
|
|
|
|
mc = _child("meshcore", connected=True, known=["aida"])
|
|
|
|
|
connector = _composite([mc], send_result=True)
|
|
|
|
|
client = _client(connector)
|
|
|
|
|
|
|
|
|
|
r = client.post("/api/mesh/test-send", json={"transport": "meshcore", "channel": "aida"})
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
assert r.json()["sent"] is True
|
|
|
|
|
|
|
|
|
|
args, kwargs = connector.send_message.call_args
|
|
|
|
|
sent_text = kwargs["text"] if "text" in kwargs else args[0]
|
|
|
|
|
assert isinstance(sent_text, str)
|
|
|
|
|
assert sent_text.startswith("🧪 MeshAI test")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# GET /api/meshcore/channels
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_meshcore_channels_active():
|
|
|
|
|
mc = _child("meshcore", connected=True, known=["aida", "emergency"])
|
|
|
|
|
connector = _composite([mc])
|
|
|
|
|
client = _client(connector)
|
|
|
|
|
|
|
|
|
|
r = client.get("/api/meshcore/channels")
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
assert r.json() == {"active": True, "channels": ["aida", "emergency"]}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_meshcore_channels_no_meshcore():
|
|
|
|
|
mt = _child("meshtastic", connected=True)
|
|
|
|
|
connector = _composite([mt])
|
|
|
|
|
client = _client(connector)
|
|
|
|
|
|
|
|
|
|
r = client.get("/api/meshcore/channels")
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
assert r.json() == {"active": False, "channels": []}
|
2026-07-03 16:14:10 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# GET /api/meshcore/contacts
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_SAMPLE_ROSTER = [
|
|
|
|
|
{
|
|
|
|
|
"name": "Repeater One",
|
|
|
|
|
"pubkey": "aa11deadbeef",
|
|
|
|
|
"type": "repeater",
|
|
|
|
|
"last_advert": 1000,
|
|
|
|
|
"lat": 43.6,
|
|
|
|
|
"lon": -116.2,
|
|
|
|
|
"out_path_len": 2,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"name": "Sensor Two",
|
|
|
|
|
"pubkey": "bb22cafef00d",
|
|
|
|
|
"type": "sensor",
|
|
|
|
|
"last_advert": 2000,
|
|
|
|
|
"lat": None,
|
|
|
|
|
"lon": None,
|
|
|
|
|
"out_path_len": -1,
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_meshcore_contacts_active():
|
|
|
|
|
mc = _child("meshcore", connected=True)
|
|
|
|
|
mc.get_contacts.return_value = list(_SAMPLE_ROSTER)
|
feat(meshcore): report the true connection + add roster/channel management
self_info() reported host/port straight from config regardless of
conn_type, so a serial companion still advertised whatever stale
meshcore_host sat in the config — the API named a device meshai was not
talking to, which is enough to send an investigation to the wrong radio.
Connection details now come from one _connection_descriptor() shared with
connect(), so the log line and the API can't drift; only the live
conn_type's fields are populated and the rest are null.
meshai's device view is otherwise built once at connect and never re-read
— contacts via ensure_contacts(), channels via _enumerate_channels(). The
lib's contact handler only ever merges (meshcore.py::_update_contacts), so
a cached roster can never shrink, and a channel provisioned on the radio
stays invisible until the process restarts. There was no refetch path at
all. Adds an explicit resync that re-reads BOTH halves: a FULL
get_contacts(lastmod=0) reconciled with replace semantics (absent contacts
are dropped) plus a channel re-enumeration, each reporting what changed.
Also adds a preventive route-health check: every region_routes cell whose
MeshCore target cannot be resolved against the live roster/channel table
is surfaced, since such a send fails silently. Room targets are matched by
pubkey prefix, exactly as the dispatcher resolves them, so a picker-stored
prefix is not misreported as dangling. Same-name/different-pubkey roster
entries are flagged too — a name alone cannot identify a contact, which is
the trap behind a room rebuilt under a new keypair.
Backend:
- meshcore_roster.py: pure reconcile_contacts / check_route_health /
find_name_collisions (no device I/O — unit-testable without a radio)
- transport: _connection_descriptor, resync, refresh_contacts,
remove_contact, import_contact, export_roster, contacts_synced_at;
auto_update_contacts enabled (configurable — it costs one incremental
fetch per advert heard, which is real chatter on a dense mesh)
- API: POST contacts/refresh, DELETE contacts/{pubkey}, GET
contacts/export, POST contacts/import, GET route-health
Frontend (existing Contacts & Companion page — no new page or nav entry):
- dangling-route + name-collision banners; resync/export/add-contact
toolbar with last-synced and the added/removed counts; staleness badges;
search, filters and sortable columns; per-contact delete behind a
confirm; Companion tab shows the real transport + target.
A full pubkey is required to delete or add: the lib resolves by prefix,
and a prefix could silently hit the wrong node.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 03:50:36 +00:00
|
|
|
mc.contacts_synced_at.return_value = 1700000000.0
|
2026-07-03 16:14:10 -06:00
|
|
|
connector = _composite([mc])
|
|
|
|
|
client = _client(connector)
|
|
|
|
|
|
|
|
|
|
r = client.get("/api/meshcore/contacts")
|
|
|
|
|
assert r.status_code == 200
|
feat(meshcore): report the true connection + add roster/channel management
self_info() reported host/port straight from config regardless of
conn_type, so a serial companion still advertised whatever stale
meshcore_host sat in the config — the API named a device meshai was not
talking to, which is enough to send an investigation to the wrong radio.
Connection details now come from one _connection_descriptor() shared with
connect(), so the log line and the API can't drift; only the live
conn_type's fields are populated and the rest are null.
meshai's device view is otherwise built once at connect and never re-read
— contacts via ensure_contacts(), channels via _enumerate_channels(). The
lib's contact handler only ever merges (meshcore.py::_update_contacts), so
a cached roster can never shrink, and a channel provisioned on the radio
stays invisible until the process restarts. There was no refetch path at
all. Adds an explicit resync that re-reads BOTH halves: a FULL
get_contacts(lastmod=0) reconciled with replace semantics (absent contacts
are dropped) plus a channel re-enumeration, each reporting what changed.
Also adds a preventive route-health check: every region_routes cell whose
MeshCore target cannot be resolved against the live roster/channel table
is surfaced, since such a send fails silently. Room targets are matched by
pubkey prefix, exactly as the dispatcher resolves them, so a picker-stored
prefix is not misreported as dangling. Same-name/different-pubkey roster
entries are flagged too — a name alone cannot identify a contact, which is
the trap behind a room rebuilt under a new keypair.
Backend:
- meshcore_roster.py: pure reconcile_contacts / check_route_health /
find_name_collisions (no device I/O — unit-testable without a radio)
- transport: _connection_descriptor, resync, refresh_contacts,
remove_contact, import_contact, export_roster, contacts_synced_at;
auto_update_contacts enabled (configurable — it costs one incremental
fetch per advert heard, which is real chatter on a dense mesh)
- API: POST contacts/refresh, DELETE contacts/{pubkey}, GET
contacts/export, POST contacts/import, GET route-health
Frontend (existing Contacts & Companion page — no new page or nav entry):
- dangling-route + name-collision banners; resync/export/add-contact
toolbar with last-synced and the added/removed counts; staleness badges;
search, filters and sortable columns; per-contact delete behind a
confirm; Companion tab shows the real transport + target.
A full pubkey is required to delete or add: the lib resolves by prefix,
and a prefix could silently hit the wrong node.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 03:50:36 +00:00
|
|
|
assert r.json() == {
|
|
|
|
|
"active": True,
|
|
|
|
|
"contacts": _SAMPLE_ROSTER,
|
|
|
|
|
"last_synced_at": 1700000000.0,
|
|
|
|
|
}
|
2026-07-03 16:14:10 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_meshcore_contacts_no_meshcore():
|
|
|
|
|
mt = _child("meshtastic", connected=True)
|
|
|
|
|
connector = _composite([mt])
|
|
|
|
|
client = _client(connector)
|
|
|
|
|
|
|
|
|
|
r = client.get("/api/meshcore/contacts")
|
|
|
|
|
assert r.status_code == 200
|
feat(meshcore): report the true connection + add roster/channel management
self_info() reported host/port straight from config regardless of
conn_type, so a serial companion still advertised whatever stale
meshcore_host sat in the config — the API named a device meshai was not
talking to, which is enough to send an investigation to the wrong radio.
Connection details now come from one _connection_descriptor() shared with
connect(), so the log line and the API can't drift; only the live
conn_type's fields are populated and the rest are null.
meshai's device view is otherwise built once at connect and never re-read
— contacts via ensure_contacts(), channels via _enumerate_channels(). The
lib's contact handler only ever merges (meshcore.py::_update_contacts), so
a cached roster can never shrink, and a channel provisioned on the radio
stays invisible until the process restarts. There was no refetch path at
all. Adds an explicit resync that re-reads BOTH halves: a FULL
get_contacts(lastmod=0) reconciled with replace semantics (absent contacts
are dropped) plus a channel re-enumeration, each reporting what changed.
Also adds a preventive route-health check: every region_routes cell whose
MeshCore target cannot be resolved against the live roster/channel table
is surfaced, since such a send fails silently. Room targets are matched by
pubkey prefix, exactly as the dispatcher resolves them, so a picker-stored
prefix is not misreported as dangling. Same-name/different-pubkey roster
entries are flagged too — a name alone cannot identify a contact, which is
the trap behind a room rebuilt under a new keypair.
Backend:
- meshcore_roster.py: pure reconcile_contacts / check_route_health /
find_name_collisions (no device I/O — unit-testable without a radio)
- transport: _connection_descriptor, resync, refresh_contacts,
remove_contact, import_contact, export_roster, contacts_synced_at;
auto_update_contacts enabled (configurable — it costs one incremental
fetch per advert heard, which is real chatter on a dense mesh)
- API: POST contacts/refresh, DELETE contacts/{pubkey}, GET
contacts/export, POST contacts/import, GET route-health
Frontend (existing Contacts & Companion page — no new page or nav entry):
- dangling-route + name-collision banners; resync/export/add-contact
toolbar with last-synced and the added/removed counts; staleness badges;
search, filters and sortable columns; per-contact delete behind a
confirm; Companion tab shows the real transport + target.
A full pubkey is required to delete or add: the lib resolves by prefix,
and a prefix could silently hit the wrong node.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 03:50:36 +00:00
|
|
|
assert r.json() == {"active": False, "contacts": [], "last_synced_at": None}
|
2026-07-03 16:14:10 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_meshcore_contacts_disconnected():
|
|
|
|
|
mc = _child("meshcore", connected=False)
|
|
|
|
|
connector = _composite([mc])
|
|
|
|
|
client = _client(connector)
|
|
|
|
|
|
|
|
|
|
r = client.get("/api/meshcore/contacts")
|
|
|
|
|
assert r.status_code == 200
|
feat(meshcore): report the true connection + add roster/channel management
self_info() reported host/port straight from config regardless of
conn_type, so a serial companion still advertised whatever stale
meshcore_host sat in the config — the API named a device meshai was not
talking to, which is enough to send an investigation to the wrong radio.
Connection details now come from one _connection_descriptor() shared with
connect(), so the log line and the API can't drift; only the live
conn_type's fields are populated and the rest are null.
meshai's device view is otherwise built once at connect and never re-read
— contacts via ensure_contacts(), channels via _enumerate_channels(). The
lib's contact handler only ever merges (meshcore.py::_update_contacts), so
a cached roster can never shrink, and a channel provisioned on the radio
stays invisible until the process restarts. There was no refetch path at
all. Adds an explicit resync that re-reads BOTH halves: a FULL
get_contacts(lastmod=0) reconciled with replace semantics (absent contacts
are dropped) plus a channel re-enumeration, each reporting what changed.
Also adds a preventive route-health check: every region_routes cell whose
MeshCore target cannot be resolved against the live roster/channel table
is surfaced, since such a send fails silently. Room targets are matched by
pubkey prefix, exactly as the dispatcher resolves them, so a picker-stored
prefix is not misreported as dangling. Same-name/different-pubkey roster
entries are flagged too — a name alone cannot identify a contact, which is
the trap behind a room rebuilt under a new keypair.
Backend:
- meshcore_roster.py: pure reconcile_contacts / check_route_health /
find_name_collisions (no device I/O — unit-testable without a radio)
- transport: _connection_descriptor, resync, refresh_contacts,
remove_contact, import_contact, export_roster, contacts_synced_at;
auto_update_contacts enabled (configurable — it costs one incremental
fetch per advert heard, which is real chatter on a dense mesh)
- API: POST contacts/refresh, DELETE contacts/{pubkey}, GET
contacts/export, POST contacts/import, GET route-health
Frontend (existing Contacts & Companion page — no new page or nav entry):
- dangling-route + name-collision banners; resync/export/add-contact
toolbar with last-synced and the added/removed counts; staleness badges;
search, filters and sortable columns; per-contact delete behind a
confirm; Companion tab shows the real transport + target.
A full pubkey is required to delete or add: the lib resolves by prefix,
and a prefix could silently hit the wrong node.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 03:50:36 +00:00
|
|
|
assert r.json() == {"active": False, "contacts": [], "last_synced_at": None}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# POST /api/meshcore/contacts/refresh — full resync + reconcile
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
_REFRESH_STATS = {
|
|
|
|
|
"before": 3, "after": 3, "added": 1, "removed": 1, "updated": 0,
|
|
|
|
|
"added_keys": ["cc" * 32], "removed_keys": ["bb" * 32],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_CHANNEL_STATS = {"before": 4, "after": 5, "added": ["#new-chan"], "removed": []}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_meshcore_refresh_returns_contact_and_channel_stats():
|
|
|
|
|
"""The resync re-reads BOTH halves of the device view, and reports each."""
|
|
|
|
|
mc = _child("meshcore", connected=True, known=["#aida", "#new-chan"])
|
|
|
|
|
mc.resync.return_value = {"contacts": dict(_REFRESH_STATS), "channels": dict(_CHANNEL_STATS)}
|
|
|
|
|
mc.get_contacts.return_value = list(_SAMPLE_ROSTER)
|
|
|
|
|
mc.contacts_synced_at.return_value = 1700000000.0
|
|
|
|
|
client = _client(_composite([mc]))
|
|
|
|
|
|
|
|
|
|
r = client.post("/api/meshcore/contacts/refresh")
|
|
|
|
|
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
body = r.json()
|
|
|
|
|
assert body["stats"] == _REFRESH_STATS
|
|
|
|
|
assert body["channel_stats"] == _CHANNEL_STATS
|
|
|
|
|
assert body["contacts"] == _SAMPLE_ROSTER
|
|
|
|
|
assert body["channels"] == ["#aida", "#new-chan"]
|
|
|
|
|
assert body["last_synced_at"] == 1700000000.0
|
|
|
|
|
mc.resync.assert_called_once()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_meshcore_refresh_conflict_when_disconnected():
|
|
|
|
|
mc = _child("meshcore", connected=False)
|
|
|
|
|
client = _client(_composite([mc]))
|
|
|
|
|
|
|
|
|
|
r = client.post("/api/meshcore/contacts/refresh")
|
|
|
|
|
|
|
|
|
|
assert r.status_code == 409
|
|
|
|
|
mc.resync.assert_not_called()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_meshcore_refresh_surfaces_companion_failure():
|
|
|
|
|
"""A failed fetch must surface, not be reported as a successful resync."""
|
|
|
|
|
mc = _child("meshcore", connected=True)
|
|
|
|
|
mc.resync.side_effect = RuntimeError("contact refresh failed: timeout")
|
|
|
|
|
client = _client(_composite([mc]))
|
|
|
|
|
|
|
|
|
|
r = client.post("/api/meshcore/contacts/refresh")
|
|
|
|
|
|
|
|
|
|
assert r.status_code == 502
|
|
|
|
|
assert "timeout" in r.json()["detail"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# DELETE /api/meshcore/contacts/{pubkey}
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
def test_meshcore_delete_contact_removes_and_returns_roster():
|
|
|
|
|
mc = _child("meshcore", connected=True)
|
|
|
|
|
mc.get_contacts.return_value = list(_SAMPLE_ROSTER)
|
|
|
|
|
client = _client(_composite([mc]))
|
|
|
|
|
|
|
|
|
|
r = client.delete(f"/api/meshcore/contacts/{'aa' * 32}")
|
|
|
|
|
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
assert r.json() == {"active": True, "contacts": _SAMPLE_ROSTER}
|
|
|
|
|
mc.remove_contact.assert_called_once_with("aa" * 32)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_meshcore_delete_contact_rejects_bad_key():
|
|
|
|
|
mc = _child("meshcore", connected=True)
|
|
|
|
|
mc.remove_contact.side_effect = ValueError("A full 64-character hex pubkey is required")
|
|
|
|
|
client = _client(_composite([mc]))
|
|
|
|
|
|
|
|
|
|
r = client.delete("/api/meshcore/contacts/aa11")
|
|
|
|
|
|
|
|
|
|
assert r.status_code == 400
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_meshcore_delete_contact_conflict_when_disconnected():
|
|
|
|
|
mc = _child("meshcore", connected=False)
|
|
|
|
|
client = _client(_composite([mc]))
|
|
|
|
|
|
|
|
|
|
r = client.delete(f"/api/meshcore/contacts/{'aa' * 32}")
|
|
|
|
|
|
|
|
|
|
assert r.status_code == 409
|
|
|
|
|
mc.remove_contact.assert_not_called()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# GET /api/meshcore/contacts/export
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
def test_meshcore_export_returns_envelope_and_attachment():
|
|
|
|
|
mc = _child("meshcore", connected=True)
|
|
|
|
|
mc.export_roster.return_value = [{"name": "N", "pubkey": "aa" * 32, "type": 1}]
|
|
|
|
|
mc.self_info.return_value = {
|
|
|
|
|
"name": "AIDA", "pubkey": "a6" * 32,
|
|
|
|
|
"conn_type": "serial", "target": "serial:/dev/meshcore-rak@115200",
|
|
|
|
|
}
|
|
|
|
|
mc.contacts_synced_at.return_value = 1700000000.0
|
|
|
|
|
client = _client(_composite([mc]))
|
|
|
|
|
|
|
|
|
|
r = client.get("/api/meshcore/contacts/export")
|
|
|
|
|
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
assert "attachment" in r.headers["content-disposition"]
|
|
|
|
|
body = r.json()
|
|
|
|
|
assert body["format"] == "meshai.meshcore.roster"
|
|
|
|
|
assert body["count"] == 1
|
|
|
|
|
# The roster is only meaningful paired with the device it came from.
|
|
|
|
|
assert body["device"]["conn_type"] == "serial"
|
|
|
|
|
assert body["device"]["target"] == "serial:/dev/meshcore-rak@115200"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_meshcore_export_conflict_when_disconnected():
|
|
|
|
|
mc = _child("meshcore", connected=False)
|
|
|
|
|
client = _client(_composite([mc]))
|
|
|
|
|
|
|
|
|
|
assert client.get("/api/meshcore/contacts/export").status_code == 409
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# POST /api/meshcore/contacts/import
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
def test_meshcore_import_writes_each_record():
|
|
|
|
|
mc = _child("meshcore", connected=True)
|
|
|
|
|
client = _client(_composite([mc]))
|
|
|
|
|
|
|
|
|
|
r = client.post("/api/meshcore/contacts/import", json={
|
|
|
|
|
"contacts": [{"pubkey": "aa" * 32}, {"pubkey": "bb" * 32}],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
assert r.json() == {"active": True, "imported": 2, "failed": 0, "errors": []}
|
|
|
|
|
assert mc.import_contact.call_count == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_meshcore_import_collects_per_record_errors():
|
|
|
|
|
"""One bad record must not strand the batch with no report of what landed."""
|
|
|
|
|
mc = _child("meshcore", connected=True)
|
|
|
|
|
mc.import_contact.side_effect = [None, ValueError("bad pubkey")]
|
|
|
|
|
client = _client(_composite([mc]))
|
|
|
|
|
|
|
|
|
|
r = client.post("/api/meshcore/contacts/import", json={
|
|
|
|
|
"contacts": [{"pubkey": "aa" * 32}, {"pubkey": "nope"}],
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
body = r.json()
|
|
|
|
|
assert body["imported"] == 1
|
|
|
|
|
assert body["failed"] == 1
|
|
|
|
|
assert body["errors"][0]["pubkey"] == "nope"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_meshcore_import_rejects_empty_payload():
|
|
|
|
|
mc = _child("meshcore", connected=True)
|
|
|
|
|
client = _client(_composite([mc]))
|
|
|
|
|
|
|
|
|
|
assert client.post("/api/meshcore/contacts/import", json={"contacts": []}).status_code == 400
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# GET /api/meshcore/route-health
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
def _config_with_cells(cells, mc_enabled=True):
|
|
|
|
|
return SimpleNamespace(
|
|
|
|
|
notifications=SimpleNamespace(
|
|
|
|
|
region_routes=SimpleNamespace(mt_enabled=True, mc_enabled=mc_enabled, cells=cells)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _health_client(connector, config):
|
|
|
|
|
app = FastAPI()
|
|
|
|
|
app.include_router(router, prefix="/api")
|
|
|
|
|
app.state.connector = connector
|
|
|
|
|
app.state.config = config
|
|
|
|
|
return TestClient(app)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_route_health_flags_dangling_room_cell():
|
|
|
|
|
mc = _child("meshcore", connected=True, known=["#aida"])
|
|
|
|
|
mc.get_contacts.return_value = []
|
|
|
|
|
config = _config_with_cells({"fire": {"SC Idaho": {"mc": f"room:{'de' * 32}", "enabled": True}}})
|
|
|
|
|
client = _health_client(_composite([mc]), config)
|
|
|
|
|
|
|
|
|
|
body = client.get("/api/meshcore/route-health").json()
|
|
|
|
|
|
|
|
|
|
assert body["active"] is True
|
|
|
|
|
assert len(body["dangling"]) == 1
|
|
|
|
|
assert body["dangling"][0]["reason"] == "room_not_found"
|
|
|
|
|
assert body["dangling_enabled"] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_route_health_clean_when_targets_resolve():
|
|
|
|
|
mc = _child("meshcore", connected=True, known=["#aida"])
|
|
|
|
|
mc.get_contacts.return_value = [
|
|
|
|
|
{"pubkey": "aa" * 32, "name": "Room", "type": 3},
|
|
|
|
|
]
|
|
|
|
|
config = _config_with_cells({
|
|
|
|
|
"weather": {
|
|
|
|
|
"SW Idaho": {"mc": "#aida", "enabled": True},
|
|
|
|
|
"SC Idaho": {"mc": f"room:{'aa' * 32}", "enabled": True},
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
client = _health_client(_composite([mc]), config)
|
|
|
|
|
|
|
|
|
|
body = client.get("/api/meshcore/route-health").json()
|
|
|
|
|
|
|
|
|
|
assert body["dangling"] == []
|
|
|
|
|
assert body["checked"] == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_route_health_reports_name_collisions():
|
|
|
|
|
mc = _child("meshcore", connected=True, known=[])
|
|
|
|
|
mc.get_contacts.return_value = [
|
|
|
|
|
{"pubkey": "aa" * 32, "name": "SC ID AIDA Alerts", "type": 3},
|
|
|
|
|
{"pubkey": "bb" * 32, "name": "SC ID AIDA Alerts", "type": 3},
|
|
|
|
|
]
|
|
|
|
|
client = _health_client(_composite([mc]), _config_with_cells({}))
|
|
|
|
|
|
|
|
|
|
body = client.get("/api/meshcore/route-health").json()
|
|
|
|
|
|
|
|
|
|
assert len(body["collisions"]) == 1
|
|
|
|
|
assert body["collisions"][0]["count"] == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_route_health_inactive_when_disconnected():
|
|
|
|
|
"""A disconnected companion is not evidence that a route is broken."""
|
|
|
|
|
mc = _child("meshcore", connected=False)
|
|
|
|
|
config = _config_with_cells({"fire": {"SC Idaho": {"mc": "room:dead", "enabled": True}}})
|
|
|
|
|
client = _health_client(_composite([mc]), config)
|
|
|
|
|
|
|
|
|
|
body = client.get("/api/meshcore/route-health").json()
|
|
|
|
|
|
|
|
|
|
assert body["active"] is False
|
|
|
|
|
assert body["dangling"] == []
|
2026-07-03 16:14:10 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# GET /api/meshcore/self
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_meshcore_self_active():
|
|
|
|
|
mc = _child("meshcore", connected=True)
|
|
|
|
|
mc.self_info.return_value = {
|
|
|
|
|
"name": "AIDA",
|
|
|
|
|
"pubkey": "deadbeef1234",
|
|
|
|
|
"connected": True,
|
|
|
|
|
"host": "100.64.0.9",
|
|
|
|
|
"port": 5050,
|
|
|
|
|
"channel_count": 2,
|
|
|
|
|
}
|
|
|
|
|
connector = _composite([mc])
|
|
|
|
|
client = _client(connector)
|
|
|
|
|
|
|
|
|
|
r = client.get("/api/meshcore/self")
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
body = r.json()
|
|
|
|
|
assert body["connected"] is True
|
|
|
|
|
assert body["pubkey"] == "deadbeef1234"
|
|
|
|
|
assert body["name"] == "AIDA"
|
|
|
|
|
assert body["channel_count"] == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_meshcore_self_no_meshcore():
|
|
|
|
|
mt = _child("meshtastic", connected=True)
|
|
|
|
|
connector = _composite([mt])
|
|
|
|
|
client = _client(connector)
|
|
|
|
|
|
|
|
|
|
r = client.get("/api/meshcore/self")
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
assert r.json() == {"connected": False}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_meshcore_self_disconnected():
|
|
|
|
|
mc = _child("meshcore", connected=False)
|
|
|
|
|
connector = _composite([mc])
|
|
|
|
|
client = _client(connector)
|
|
|
|
|
|
|
|
|
|
r = client.get("/api/meshcore/self")
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
assert r.json() == {"connected": False}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# POST /api/meshcore/advert
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_meshcore_advert_connected_returns_sent_true():
|
|
|
|
|
"""POST /api/meshcore/advert → {sent: true} when meshcore is connected."""
|
|
|
|
|
mc = _child("meshcore", connected=True)
|
|
|
|
|
mc.send_advert.return_value = True
|
|
|
|
|
connector = _composite([mc])
|
|
|
|
|
client = _client(connector)
|
|
|
|
|
|
|
|
|
|
r = client.post("/api/meshcore/advert")
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
body = r.json()
|
|
|
|
|
assert body["sent"] is True
|
|
|
|
|
assert "detail" in body
|
|
|
|
|
mc.send_advert.assert_called_once()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_meshcore_advert_connected_send_returns_false():
|
|
|
|
|
"""POST /api/meshcore/advert → {sent: false} when send_advert() returns False."""
|
|
|
|
|
mc = _child("meshcore", connected=True)
|
|
|
|
|
mc.send_advert.return_value = False
|
|
|
|
|
connector = _composite([mc])
|
|
|
|
|
client = _client(connector)
|
|
|
|
|
|
|
|
|
|
r = client.post("/api/meshcore/advert")
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
body = r.json()
|
|
|
|
|
assert body["sent"] is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_meshcore_advert_not_connected():
|
|
|
|
|
"""POST /api/meshcore/advert → {sent: false, detail: 'MeshCore not connected'}."""
|
|
|
|
|
mc = _child("meshcore", connected=False)
|
|
|
|
|
connector = _composite([mc])
|
|
|
|
|
client = _client(connector)
|
|
|
|
|
|
|
|
|
|
r = client.post("/api/meshcore/advert")
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
body = r.json()
|
|
|
|
|
assert body["sent"] is False
|
|
|
|
|
assert body["detail"] == "MeshCore not connected"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_meshcore_advert_no_meshcore_child():
|
|
|
|
|
"""POST /api/meshcore/advert → {sent: false} when there is no meshcore transport."""
|
|
|
|
|
mt = _child("meshtastic", connected=True)
|
|
|
|
|
connector = _composite([mt])
|
|
|
|
|
client = _client(connector)
|
|
|
|
|
|
|
|
|
|
r = client.post("/api/meshcore/advert")
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
body = r.json()
|
|
|
|
|
assert body["sent"] is False
|
|
|
|
|
assert body["detail"] == "MeshCore not connected"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# Config round-trip: meshcore_advert_interval_seconds
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_connection_config_advert_interval_default():
|
|
|
|
|
"""meshcore_advert_interval_seconds defaults to 10800 (3 h)."""
|
|
|
|
|
from meshai.config import ConnectionConfig
|
|
|
|
|
cfg = ConnectionConfig()
|
|
|
|
|
assert cfg.meshcore_advert_interval_seconds == 10800
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_connection_config_advert_interval_zero():
|
|
|
|
|
"""meshcore_advert_interval_seconds = 0 disables periodic advert."""
|
|
|
|
|
from meshai.config import ConnectionConfig
|
|
|
|
|
cfg = ConnectionConfig(meshcore_advert_interval_seconds=0)
|
|
|
|
|
assert cfg.meshcore_advert_interval_seconds == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_connection_config_advert_interval_round_trips_yaml():
|
|
|
|
|
"""meshcore_advert_interval_seconds survives YAML serialize → deserialize."""
|
|
|
|
|
from meshai.config import ConnectionConfig, _dataclass_to_dict, _dict_to_dataclass
|
|
|
|
|
cfg = ConnectionConfig(meshcore_advert_interval_seconds=7200)
|
|
|
|
|
data = _dataclass_to_dict(cfg)
|
|
|
|
|
assert data["meshcore_advert_interval_seconds"] == 7200
|
|
|
|
|
cfg2 = _dict_to_dataclass(ConnectionConfig, data)
|
|
|
|
|
assert cfg2.meshcore_advert_interval_seconds == 7200
|