mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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>
This commit is contained in:
parent
bbe97398bc
commit
aa18f642aa
14 changed files with 2970 additions and 502 deletions
|
|
@ -6,6 +6,7 @@ Uses a bare FastAPI() + TestClient with a hand-seeded ``app.state.connector``
|
|||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
|
@ -198,12 +199,17 @@ _SAMPLE_ROSTER = [
|
|||
def test_meshcore_contacts_active():
|
||||
mc = _child("meshcore", connected=True)
|
||||
mc.get_contacts.return_value = list(_SAMPLE_ROSTER)
|
||||
mc.contacts_synced_at.return_value = 1700000000.0
|
||||
connector = _composite([mc])
|
||||
client = _client(connector)
|
||||
|
||||
r = client.get("/api/meshcore/contacts")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"active": True, "contacts": _SAMPLE_ROSTER}
|
||||
assert r.json() == {
|
||||
"active": True,
|
||||
"contacts": _SAMPLE_ROSTER,
|
||||
"last_synced_at": 1700000000.0,
|
||||
}
|
||||
|
||||
|
||||
def test_meshcore_contacts_no_meshcore():
|
||||
|
|
@ -213,7 +219,7 @@ def test_meshcore_contacts_no_meshcore():
|
|||
|
||||
r = client.get("/api/meshcore/contacts")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"active": False, "contacts": []}
|
||||
assert r.json() == {"active": False, "contacts": [], "last_synced_at": None}
|
||||
|
||||
|
||||
def test_meshcore_contacts_disconnected():
|
||||
|
|
@ -223,7 +229,250 @@ def test_meshcore_contacts_disconnected():
|
|||
|
||||
r = client.get("/api/meshcore/contacts")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"active": False, "contacts": []}
|
||||
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"] == []
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue