mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
MeshCore Contacts roster + Companion status (read-only) (#17)
* feat(dashboard): MeshCore Contacts roster + Companion status (read-only) Expose the live companion's contact roster (get_contacts) and self/channel status via /api/meshcore/contacts + /api/meshcore/self. Fill the Contacts (roster table) and Companion (status + channels) pages. Telemetry auto-poll comes next. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(meshcore): self-advertisement (send-advert + advert-on-connect + periodic) AIDA now announces itself: send_advert(flood=True) on every connect, an optional periodic auto-advert (meshcore_advert_interval_seconds), and a manual "Send Advert" button + POST /api/meshcore/advert. Makes the companion discoverable/DM-able on the mesh. --------- Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
61ca0057e2
commit
284fb5cbf2
9 changed files with 1142 additions and 42 deletions
|
|
@ -157,3 +157,196 @@ def test_meshcore_channels_no_meshcore():
|
|||
r = client.get("/api/meshcore/channels")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"active": False, "channels": []}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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)
|
||||
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}
|
||||
|
||||
|
||||
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
|
||||
assert r.json() == {"active": False, "contacts": []}
|
||||
|
||||
|
||||
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
|
||||
assert r.json() == {"active": False, "contacts": []}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -70,6 +70,11 @@ def _build_fake_meshcore():
|
|||
result.is_error.return_value = False
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def send_advert(flood=False):
|
||||
# No return value required for advert.
|
||||
pass
|
||||
|
||||
mod.MeshCore = _FakeMeshCore
|
||||
return mod
|
||||
|
||||
|
|
@ -508,3 +513,284 @@ class TestMyNodeId:
|
|||
t = MeshCoreTransport(_mc_config())
|
||||
t._self_info = {}
|
||||
assert t.my_node_id is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. get_contacts() — roster mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Sample companion contact table: pubkey_hex -> raw contact dict, mirroring the
|
||||
# meshcore lib's ``mc.contacts`` shape (repeater + sensor, with/without pos).
|
||||
_SAMPLE_CONTACTS = {
|
||||
"aa11": {
|
||||
"adv_name": "Repeater One",
|
||||
"public_key": "aa11deadbeef",
|
||||
"type": "repeater",
|
||||
"last_advert": 1000,
|
||||
"adv_lat": 43.6,
|
||||
"adv_lon": -116.2,
|
||||
"out_path_len": 2,
|
||||
},
|
||||
"bb22": {
|
||||
"adv_name": "Sensor Two",
|
||||
"public_key": "bb22cafef00d",
|
||||
"type": "sensor",
|
||||
"last_advert": 2000,
|
||||
"adv_lat": None,
|
||||
"adv_lon": None,
|
||||
"out_path_len": -1,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestGetContacts:
|
||||
def test_maps_contacts_into_roster_shape(self):
|
||||
"""mc.contacts dict is mapped into the roster shape (repeater + sensor)."""
|
||||
t, mc, _ = _transport_with_mock_mc()
|
||||
try:
|
||||
mc.ensure_contacts = AsyncMock(return_value=None)
|
||||
mc.contacts = dict(_SAMPLE_CONTACTS)
|
||||
roster = t.get_contacts()
|
||||
assert isinstance(roster, list)
|
||||
assert len(roster) == 2
|
||||
by_name = {r["name"]: r for r in roster}
|
||||
|
||||
rep = by_name["Repeater One"]
|
||||
assert rep == {
|
||||
"name": "Repeater One",
|
||||
"pubkey": "aa11deadbeef",
|
||||
"type": "repeater",
|
||||
"last_advert": 1000,
|
||||
"lat": 43.6,
|
||||
"lon": -116.2,
|
||||
"out_path_len": 2,
|
||||
}
|
||||
|
||||
sensor = by_name["Sensor Two"]
|
||||
assert sensor["type"] == "sensor"
|
||||
assert sensor["pubkey"] == "bb22cafef00d"
|
||||
assert sensor["lat"] is None
|
||||
assert sensor["lon"] is None
|
||||
assert sensor["out_path_len"] == -1
|
||||
finally:
|
||||
_cleanup(t)
|
||||
|
||||
def test_pubkey_falls_back_to_hex_key(self):
|
||||
"""When a contact carries no public_key, the dict hex key is used."""
|
||||
t, mc, _ = _transport_with_mock_mc()
|
||||
try:
|
||||
mc.ensure_contacts = AsyncMock(return_value=None)
|
||||
mc.contacts = {"ff00": {"adv_name": "NoKey", "type": "chat"}}
|
||||
roster = t.get_contacts()
|
||||
assert len(roster) == 1
|
||||
assert roster[0]["pubkey"] == "ff00"
|
||||
assert roster[0]["name"] == "NoKey"
|
||||
finally:
|
||||
_cleanup(t)
|
||||
|
||||
def test_works_without_ensure_contacts(self):
|
||||
"""A companion lacking ensure_contacts still yields the roster."""
|
||||
t, mc, _ = _transport_with_mock_mc()
|
||||
try:
|
||||
mc.ensure_contacts = None
|
||||
mc.contacts = dict(_SAMPLE_CONTACTS)
|
||||
roster = t.get_contacts()
|
||||
assert len(roster) == 2
|
||||
finally:
|
||||
_cleanup(t)
|
||||
|
||||
def test_returns_empty_when_not_connected(self):
|
||||
"""A fresh, unconnected transport (_mc is None) returns []."""
|
||||
t = MeshCoreTransport(_mc_config())
|
||||
assert t.get_contacts() == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. self_info() — companion self/connection status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSelfInfo:
|
||||
def test_connected_returns_status_dict(self):
|
||||
"""Connected: returns name/pubkey/connected/host/port/channel_count."""
|
||||
t, mc, _ = _transport_with_mock_mc()
|
||||
try:
|
||||
# Build _chan_name_to_idx from the fixture's fake channel table so
|
||||
# channel_count is non-zero (fixture wires get_channel but doesn't enumerate).
|
||||
t._enumerate_channels()
|
||||
t._self_info = {"public_key": "deadbeef1234", "name": "AIDA"}
|
||||
info = t.self_info()
|
||||
assert info["name"] == "AIDA"
|
||||
assert info["pubkey"] == "deadbeef1234"
|
||||
assert info["connected"] is True
|
||||
assert info["host"] == "127.0.0.1"
|
||||
assert info["port"] == 5050
|
||||
# channel_count reflects the installed fake channel table.
|
||||
assert info["channel_count"] == len(t.known_channels())
|
||||
assert info["channel_count"] == 2
|
||||
finally:
|
||||
_cleanup(t)
|
||||
|
||||
def test_not_connected_returns_disconnected(self):
|
||||
"""A fresh, unconnected transport (_mc is None) returns {connected: False}."""
|
||||
t = MeshCoreTransport(_mc_config())
|
||||
assert t.self_info() == {"connected": False}
|
||||
|
||||
def test_connected_includes_last_advert_sent(self):
|
||||
"""self_info() includes last_advert_sent (None before first advert)."""
|
||||
t, mc, _ = _transport_with_mock_mc()
|
||||
try:
|
||||
t._self_info = {"public_key": "abc123", "name": "TestNode"}
|
||||
info = t.self_info()
|
||||
assert "last_advert_sent" in info
|
||||
assert info["last_advert_sent"] is None # no advert sent yet
|
||||
finally:
|
||||
_cleanup(t)
|
||||
|
||||
def test_self_info_last_advert_sent_updated_after_send_advert(self):
|
||||
"""self_info() reflects last_advert_sent after send_advert() succeeds."""
|
||||
import time
|
||||
t, mc, _ = _transport_with_mock_mc()
|
||||
try:
|
||||
mc.commands.send_advert = AsyncMock(return_value=None)
|
||||
t._self_info = {"public_key": "abc123", "name": "TestNode"}
|
||||
before = time.time()
|
||||
t.send_advert()
|
||||
info = t.self_info()
|
||||
assert info["last_advert_sent"] is not None
|
||||
assert info["last_advert_sent"] >= before
|
||||
finally:
|
||||
_cleanup(t)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. send_advert()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSendAdvert:
|
||||
def test_connected_calls_lib_command_and_returns_true(self):
|
||||
"""send_advert() awaits mc.commands.send_advert(flood=True) and returns True."""
|
||||
t, mc, _ = _transport_with_mock_mc()
|
||||
try:
|
||||
mc.commands.send_advert = AsyncMock(return_value=None)
|
||||
result = t.send_advert()
|
||||
assert result is True
|
||||
mc.commands.send_advert.assert_awaited_once_with(flood=True)
|
||||
finally:
|
||||
_cleanup(t)
|
||||
|
||||
def test_not_connected_returns_false(self):
|
||||
"""send_advert() returns False when _mc is None (transport not connected)."""
|
||||
t = MeshCoreTransport(_mc_config())
|
||||
assert t.send_advert() is False
|
||||
|
||||
def test_connected_but_flag_false_returns_false(self):
|
||||
"""send_advert() returns False when _connected is False."""
|
||||
t = MeshCoreTransport(_mc_config())
|
||||
t._mc = MagicMock() # mc set but _connected remains False
|
||||
assert t.send_advert() is False
|
||||
|
||||
def test_updates_last_advert_sent_on_success(self):
|
||||
"""send_advert() sets _last_advert_sent to current epoch on success."""
|
||||
import time
|
||||
t, mc, _ = _transport_with_mock_mc()
|
||||
try:
|
||||
mc.commands.send_advert = AsyncMock(return_value=None)
|
||||
before = time.time()
|
||||
t.send_advert()
|
||||
assert t._last_advert_sent is not None
|
||||
assert t._last_advert_sent >= before
|
||||
finally:
|
||||
_cleanup(t)
|
||||
|
||||
def test_exception_returns_false_and_does_not_raise(self):
|
||||
"""send_advert() returns False (never raises) when the lib command raises."""
|
||||
t, mc, _ = _transport_with_mock_mc()
|
||||
try:
|
||||
mc.commands.send_advert = AsyncMock(side_effect=Exception("timeout"))
|
||||
result = t.send_advert()
|
||||
assert result is False
|
||||
finally:
|
||||
_cleanup(t)
|
||||
|
||||
def test_does_not_update_last_advert_sent_on_failure(self):
|
||||
"""_last_advert_sent stays None when the lib command raises."""
|
||||
t, mc, _ = _transport_with_mock_mc()
|
||||
try:
|
||||
mc.commands.send_advert = AsyncMock(side_effect=Exception("timeout"))
|
||||
t.send_advert()
|
||||
assert t._last_advert_sent is None
|
||||
finally:
|
||||
_cleanup(t)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. advert-on-connect
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAdvertOnConnect:
|
||||
def test_advert_sent_after_connect(self):
|
||||
"""connect() calls send_advert() once after _enumerate_channels()."""
|
||||
from unittest.mock import patch
|
||||
cfg = _mc_config()
|
||||
# Disable periodic advert so we only check the one-shot on-connect call.
|
||||
cfg.meshcore_advert_interval_seconds = 0
|
||||
t = MeshCoreTransport(cfg)
|
||||
advert_calls = []
|
||||
|
||||
original_send_advert = MeshCoreTransport.send_advert
|
||||
|
||||
def _spy_send_advert(self_inner):
|
||||
advert_calls.append(True)
|
||||
return True
|
||||
|
||||
with patch.object(MeshCoreTransport, "send_advert", _spy_send_advert):
|
||||
t.connect()
|
||||
|
||||
try:
|
||||
assert len(advert_calls) == 1, (
|
||||
f"expected 1 send_advert call on connect, got {len(advert_calls)}"
|
||||
)
|
||||
finally:
|
||||
t.disconnect()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11. Periodic advert scheduler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPeriodicAdvertScheduler:
|
||||
def test_task_armed_when_interval_nonzero(self):
|
||||
"""connect() with meshcore_advert_interval_seconds > 0 arms _advert_task."""
|
||||
import time
|
||||
cfg = _mc_config(meshcore_advert_interval_seconds=3600)
|
||||
t = MeshCoreTransport(cfg)
|
||||
try:
|
||||
t.connect()
|
||||
# Give the event loop a moment to execute the call_soon_threadsafe callback.
|
||||
time.sleep(0.1)
|
||||
assert t._advert_task is not None, "_advert_task should be set after connect"
|
||||
finally:
|
||||
t.disconnect()
|
||||
|
||||
def test_task_not_armed_when_interval_zero(self):
|
||||
"""connect() with meshcore_advert_interval_seconds=0 leaves _advert_task None."""
|
||||
import time
|
||||
cfg = _mc_config(meshcore_advert_interval_seconds=0)
|
||||
t = MeshCoreTransport(cfg)
|
||||
try:
|
||||
t.connect()
|
||||
time.sleep(0.1)
|
||||
assert t._advert_task is None, "_advert_task should not be set when interval=0"
|
||||
finally:
|
||||
t.disconnect()
|
||||
|
||||
def test_task_cleared_after_disconnect(self):
|
||||
"""disconnect() cancels and clears _advert_task."""
|
||||
import time
|
||||
cfg = _mc_config(meshcore_advert_interval_seconds=3600)
|
||||
t = MeshCoreTransport(cfg)
|
||||
t.connect()
|
||||
time.sleep(0.1)
|
||||
assert t._advert_task is not None
|
||||
t.disconnect()
|
||||
assert t._advert_task is None, "_advert_task should be None after disconnect"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue