From 284fb5cbf2cf6d9587bfe479e7b49593aa5ae855 Mon Sep 17 00:00:00 2001 From: malice Date: Fri, 3 Jul 2026 16:14:10 -0600 Subject: [PATCH] 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) * 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 Co-authored-by: Claude Opus 4.8 (1M context) --- work/dashboard-frontend/src/lib/api.ts | 42 +++ .../src/pages/MeshCoreCompanion.tsx | 302 ++++++++++++++++-- .../src/pages/MeshCoreContacts.tsx | 168 ++++++++-- work/meshai/config.py | 1 + work/meshai/dashboard/api/mesh_send_routes.py | 48 +++ work/meshai/transport/composite_transport.py | 15 + work/meshai/transport/meshcore_transport.py | 131 ++++++++ work/tests/test_mesh_send_api.py | 193 +++++++++++ work/tests/test_meshcore_transport.py | 286 +++++++++++++++++ 9 files changed, 1143 insertions(+), 43 deletions(-) diff --git a/work/dashboard-frontend/src/lib/api.ts b/work/dashboard-frontend/src/lib/api.ts index c642158..3e47345 100644 --- a/work/dashboard-frontend/src/lib/api.ts +++ b/work/dashboard-frontend/src/lib/api.ts @@ -492,6 +492,48 @@ export async function getMeshcoreChannels(): Promise { return fetchJson('/api/meshcore/channels') } +export interface MeshcoreContact { + name: string | null + pubkey: string + type: string | null + last_advert: number | null + lat: number | null + lon: number | null + out_path_len: number | null +} +export interface MeshcoreContacts { + active: boolean + contacts: MeshcoreContact[] +} +export interface MeshcoreSelf { + name?: string | null + pubkey?: string | null + connected: boolean + host?: string + port?: number + channel_count?: number + last_advert_sent?: number | null // epoch seconds; null/absent = never advertised +} + +export async function fetchMeshcoreContacts(): Promise { + return fetchJson('/api/meshcore/contacts') +} +export async function fetchMeshcoreSelf(): Promise { + return fetchJson('/api/meshcore/self') +} + +export async function sendMeshcoreAdvert(): Promise { + const response = await fetch('/api/meshcore/advert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + if (!response.ok) { + throw new Error(`API error: ${response.status} ${response.statusText}`) + } + return response.json() +} + export async function sendTestMessage(body: { transport: 'meshtastic' | 'meshcore' channel: string | number diff --git a/work/dashboard-frontend/src/pages/MeshCoreCompanion.tsx b/work/dashboard-frontend/src/pages/MeshCoreCompanion.tsx index ec3c4f7..52eeb00 100644 --- a/work/dashboard-frontend/src/pages/MeshCoreCompanion.tsx +++ b/work/dashboard-frontend/src/pages/MeshCoreCompanion.tsx @@ -1,34 +1,292 @@ -import { useEffect } from 'react' -import { Bot } from 'lucide-react' +import { useState, useEffect, useCallback } from 'react' +import { Bot, Radio } from 'lucide-react' +import { + fetchMeshcoreSelf, + getMeshcoreChannels, + sendMeshcoreAdvert, + updateConfig, + type MeshcoreSelf, + type MeshcoreChannels, + type TestSendResult, +} from '../lib/api' + +/** Format epoch seconds as a human-readable relative time string. */ +function relativeTime(epochSec: number): string { + const diffSec = Math.floor(Date.now() / 1000 - epochSec) + if (diffSec < 5) return 'just now' + if (diffSec < 60) return `${diffSec}s ago` + const diffMin = Math.floor(diffSec / 60) + if (diffMin < 60) return `${diffMin}m ago` + const diffHr = Math.floor(diffMin / 60) + if (diffHr < 24) return `${diffHr}h ago` + return `${Math.floor(diffHr / 24)}d ago` +} export default function MeshCoreCompanion() { + const [self, setSelf] = useState(null) + const [channels, setChannels] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // Send Advert state + const [advertSending, setAdvertSending] = useState(false) + const [advertResult, setAdvertResult] = useState(null) + + // Auto-advert control state — interval in hours (0 = disabled) + // Loaded from connection config; editable in-page and PUTted back. + const [advertIntervalHours, setAdvertIntervalHours] = useState(3) + const [advertIntervalSaving, setAdvertIntervalSaving] = useState(false) + const [advertIntervalSaved, setAdvertIntervalSaved] = useState(false) + useEffect(() => { document.title = 'Companion & Channels - MeshAI' }, []) + useEffect(() => { + let cancelled = false + ;(async () => { + setLoading(true) + setError(null) + try { + const [selfData, channelData] = await Promise.all([ + fetchMeshcoreSelf(), + getMeshcoreChannels(), + ]) + if (cancelled) return + setSelf(selfData) + setChannels(channelData) + } catch (err) { + if (cancelled) return + setError(err instanceof Error ? err.message : 'Failed to load companion status') + } finally { + if (!cancelled) setLoading(false) + } + })() + return () => { + cancelled = true + } + }, []) + + // Load advert interval from connection config on mount. + useEffect(() => { + ;(async () => { + try { + const resp = await fetch('/api/config/connection') + if (resp.ok) { + const data = await resp.json() as Record + const sec = data['meshcore_advert_interval_seconds'] + if (typeof sec === 'number') { + setAdvertIntervalHours(sec > 0 ? sec / 3600 : 0) + } + } + } catch { + // non-fatal — keep default + } + })() + }, []) + + const handleSendAdvert = useCallback(async () => { + setAdvertSending(true) + setAdvertResult(null) + try { + const result = await sendMeshcoreAdvert() + setAdvertResult(result) + if (result.sent) { + // Refresh self to pick up updated last_advert_sent. + try { + const updated = await fetchMeshcoreSelf() + setSelf(updated) + } catch { + // non-fatal + } + } + } catch (err) { + setAdvertResult({ + sent: false, + detail: err instanceof Error ? err.message : 'Request failed', + }) + } finally { + setAdvertSending(false) + } + }, []) + + const handleSaveAdvertInterval = useCallback(async () => { + setAdvertIntervalSaving(true) + setAdvertIntervalSaved(false) + try { + const seconds = Math.round(advertIntervalHours * 3600) + await updateConfig('connection', { meshcore_advert_interval_seconds: seconds }) + setAdvertIntervalSaved(true) + setTimeout(() => setAdvertIntervalSaved(false), 2000) + } catch { + // keep saving=false, let UI show failure implicitly + } finally { + setAdvertIntervalSaving(false) + } + }, [advertIntervalHours]) + + const connected = self?.connected === true + const channelNames = channels?.active ? channels.channels : [] + return ( -
-
-
-
- -
-
-
-

Companion & Channels

- - Coming soon - -
-

- This page will show live status for the AIDA MeshCore companion — its connection - health and the list of channels it is currently joined to. Once the companion status - API is available, you'll be able to monitor the companion here and see which channels - are reachable for broadcast delivery. -

-
+
+ {/* Header */} +
+
+ +
+
+

Companion & Channels

+

+ Live status for the AIDA MeshCore companion and its joined channels. +

+ + {loading ? ( +
+
Loading...
+
+ ) : error ? ( +
+ {error} +
+ ) : ( + <> + {/* Status card */} +
+ {connected ? ( +
+
+ + Connected +
+
+
+
Node name
+
{self?.name ?? 'unnamed'}
+
+
+
Host
+
+ {self?.host ?? '—'} + {self?.port != null ? `:${self.port}` : ''} +
+
+
+
Public key
+
+ {self?.pubkey ?? '—'} +
+
+
+
Channels joined
+
{self?.channel_count ?? 0}
+
+ {self?.last_advert_sent != null && ( +
+
Last advertised
+
{relativeTime(self.last_advert_sent)}
+
+ )} +
+ + {/* Send Advert */} +
+
+ + {advertResult != null && ( + + {advertResult.sent ? 'Advert sent' : advertResult.detail} + + )} +
+

+ Announce this node to the mesh so others can discover and DM it. +

+
+
+ ) : ( +
+
+ + Not connected +
+

+ The MeshCore companion is offline or inactive. No node identity or channel + membership is available while the companion is disconnected. +

+
+ )} +
+ + {/* Advertising settings */} +
+
+

Advertising

+
+
+
+ +
+ + +
+

+ AIDA sends a flood advertisement at this interval so it stays discoverable. + Stored in connection.meshcore_advert_interval_seconds. +

+
+
+
+ + {/* Channel list */} +
+
+

Channels

+
+ {channelNames.length > 0 ? ( +
    + {channelNames.map((name) => ( +
  • + {name} +
  • + ))} +
+ ) : ( +
No channels
+ )} +
+ + )}
) } diff --git a/work/dashboard-frontend/src/pages/MeshCoreContacts.tsx b/work/dashboard-frontend/src/pages/MeshCoreContacts.tsx index 59bdae1..f9005d0 100644 --- a/work/dashboard-frontend/src/pages/MeshCoreContacts.tsx +++ b/work/dashboard-frontend/src/pages/MeshCoreContacts.tsx @@ -1,34 +1,160 @@ -import { useEffect } from 'react' +import { useState, useEffect } from 'react' import { Users } from 'lucide-react' +import { + fetchMeshcoreContacts, + type MeshcoreContacts, + type MeshcoreContact, +} from '../lib/api' + +function relativeTime(epochSeconds: number | null): string { + if (epochSeconds == null) return '—' + const diff = Math.floor(Date.now() / 1000) - epochSeconds + if (diff < 0) return 'just now' + if (diff < 60) return `${diff}s ago` + const mins = Math.floor(diff / 60) + if (mins < 60) return `${mins}m ago` + const hours = Math.floor(mins / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + return `${days}d ago` +} + +const TYPE_BADGES: Record = { + chat: { label: 'Chat', className: 'bg-sky-500/15 text-sky-400' }, + repeater: { label: 'Repeater', className: 'bg-amber-500/15 text-amber-400' }, + room: { label: 'Room', className: 'bg-violet-500/15 text-violet-400' }, + sensor: { label: 'Sensor', className: 'bg-emerald-500/15 text-emerald-400' }, +} + +function TypeBadge({ type }: { type: string | null }) { + const meta = (type && TYPE_BADGES[type]) || { + label: type ?? 'unknown', + className: 'bg-slate-600/30 text-slate-400', + } + return ( + + {meta.label} + + ) +} + +function contactName(c: MeshcoreContact): string { + if (c.name) return c.name + if (c.pubkey) return `${c.pubkey.slice(0, 12)}…` + return 'unnamed' +} + +function shortPubkey(pubkey: string): string { + return pubkey.length > 12 ? `${pubkey.slice(0, 12)}…` : pubkey +} + +function position(c: MeshcoreContact): string { + if (c.lat != null && c.lon != null) { + return `${c.lat.toFixed(4)}, ${c.lon.toFixed(4)}` + } + return '—' +} export default function MeshCoreContacts() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + useEffect(() => { document.title = 'MeshCore Contacts - MeshAI' }, []) + useEffect(() => { + let cancelled = false + ;(async () => { + setLoading(true) + setError(null) + try { + const result = await fetchMeshcoreContacts() + if (!cancelled) setData(result) + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : 'Failed to load contacts') + } + } finally { + if (!cancelled) setLoading(false) + } + })() + return () => { + cancelled = true + } + }, []) + return ( -
-
-
-
- -
-
-
-

MeshCore Contacts

- - Coming soon - -
-

- This page will show the MeshCore companion's contact roster — the names, public - keys, last-heard timestamps, and positions of the nodes your companion knows about. - It becomes available once the companion data API is wired up, at which point contacts - can be browsed here and referenced directly when configuring MeshCore DM delivery. -

-
+
+ {/* Header */} +
+
+ +
+
+

MeshCore Contacts

+

+ The companion's known contact roster — names, types, and last-heard times. +

+ + {loading ? ( +
+
Loading...
+
+ ) : error ? ( +
+ {error} +
+ ) : data && data.active === false ? ( +
+

+ The MeshCore companion is not connected. The contact roster is unavailable until the + companion comes online. +

+
+ ) : data && data.contacts.length === 0 ? ( +
+

+ No contacts yet. The companion is connected but has not discovered any nodes so far. +

+
+ ) : ( +
+ + + + + + + + + + + + {(data?.contacts ?? []).map((c) => ( + + + + + + + + ))} + +
NameTypeLast heardPositionPubkey
{contactName(c)} + + {relativeTime(c.last_advert)}{position(c)} + {shortPubkey(c.pubkey)} +
+
+ )} + +

+ Telemetry auto-poll is coming in the next pass. +

) } diff --git a/work/meshai/config.py b/work/meshai/config.py index c53f710..da93c0b 100644 --- a/work/meshai/config.py +++ b/work/meshai/config.py @@ -42,6 +42,7 @@ class ConnectionConfig: meshcore_port: int = 5050 # pyMC companion frame server port meshcore_auto_reconnect: bool = True # enable meshcore lib auto-reconnect meshcore_max_reconnect_attempts: int = 5 # max reconnect attempts (0 = unlimited) + meshcore_advert_interval_seconds: int = 10800 # periodic self-advert interval (0 = disabled) @dataclass diff --git a/work/meshai/dashboard/api/mesh_send_routes.py b/work/meshai/dashboard/api/mesh_send_routes.py index 953a6af..c51d805 100644 --- a/work/meshai/dashboard/api/mesh_send_routes.py +++ b/work/meshai/dashboard/api/mesh_send_routes.py @@ -39,6 +39,54 @@ async def meshcore_channels(request: Request): return {"active": False, "channels": []} +@router.get("/meshcore/contacts") +async def meshcore_contacts(request: Request): + """Roster of known MeshCore contacts if a meshcore transport is connected.""" + connector = getattr(request.app.state, "connector", None) + mc = _find_child(connector, "meshcore") + if mc is not None and getattr(mc, "connected", False): + try: + contacts = list(mc.get_contacts()) + except Exception: + contacts = [] + return {"active": True, "contacts": contacts} + return {"active": False, "contacts": []} + + +@router.get("/meshcore/self") +async def meshcore_self(request: Request): + """Companion self/connection status if a meshcore transport is connected.""" + connector = getattr(request.app.state, "connector", None) + mc = _find_child(connector, "meshcore") + if mc is not None and getattr(mc, "connected", False): + try: + return mc.self_info() + except Exception: + return {"connected": False} + return {"connected": False} + + +@router.post("/meshcore/advert") +async def meshcore_send_advert(request: Request): + """Broadcast a signed self-advertisement (flood=True) via MeshCore. + + Returns {sent: bool, detail: str}. Returns {sent: false} when MeshCore + is not connected. + """ + connector = getattr(request.app.state, "connector", None) + mc = _find_child(connector, "meshcore") + if mc is None or not getattr(mc, "connected", False): + return {"sent": False, "detail": "MeshCore not connected"} + try: + ok = bool(mc.send_advert()) + detail = "Self-advert sent" if ok else "send_advert returned False" + logger.info("dashboard: meshcore manual advert sent=%s", ok) + return {"sent": ok, "detail": detail} + except Exception as exc: + logger.error("dashboard: meshcore advert error: %s", exc) + return {"sent": False, "detail": str(exc)} + + class TestSendRequest(BaseModel): transport: str channel: Union[str, int] diff --git a/work/meshai/transport/composite_transport.py b/work/meshai/transport/composite_transport.py index c69b667..f358dd7 100644 --- a/work/meshai/transport/composite_transport.py +++ b/work/meshai/transport/composite_transport.py @@ -92,6 +92,21 @@ class CompositeTransport(MeshTransport): child = self.meshcore_child() return child.known_channels() if child is not None else [] + def get_contacts(self) -> List[dict]: + """Passthrough to the MeshCore child's contact roster; [] if no meshcore child.""" + child = self.meshcore_child() + return child.get_contacts() if child is not None else [] + + def self_info(self) -> dict: + """Passthrough to the MeshCore child's self/connection status; {connected: False} if no meshcore child.""" + child = self.meshcore_child() + return child.self_info() if child is not None else {"connected": False} + + def send_advert(self) -> bool: + """Passthrough to the MeshCore child's send_advert(); False if no meshcore child.""" + child = self.meshcore_child() + return child.send_advert() if child is not None else False + # ------------------------------------------------------------------ # Routing decision helpers (factored out for unit-test access) # ------------------------------------------------------------------ diff --git a/work/meshai/transport/meshcore_transport.py b/work/meshai/transport/meshcore_transport.py index f1db82b..45b76ef 100644 --- a/work/meshai/transport/meshcore_transport.py +++ b/work/meshai/transport/meshcore_transport.py @@ -13,6 +13,7 @@ imported (and the test suite can run) without the lib installed. import asyncio import logging import threading +import time as _time from typing import Callable, Optional from .base import MeshTransport @@ -82,6 +83,10 @@ class MeshCoreTransport(MeshTransport): # Companion channel table: channel NAME -> slot index, built at # connect time by _enumerate_channels(). Empty until connected. self._chan_name_to_idx: dict[str, int] = {} + # Self-advertisement tracking. + self._last_advert_sent: Optional[float] = None # epoch seconds or None + # asyncio.Task handle for the periodic advert loop; None when inactive. + self._advert_task = None # ------------------------------------------------------------------ # Internal helpers @@ -170,6 +175,47 @@ class MeshCoreTransport(MeshTransport): """Enumerated MeshCore channel names (from _chan_name_to_idx, populated at connect).""" return list(self._chan_name_to_idx.keys()) + def get_contacts(self) -> list[dict]: + """Roster of known MeshCore contacts. [] if not connected.""" + if self._mc is None or not self._connected: + return [] + try: + ensure = getattr(self._mc, "ensure_contacts", None) + if ensure is not None: + self._run_coro(ensure()) + except Exception: + pass + contacts = getattr(self._mc, "contacts", None) or {} + roster: list[dict] = [] + for pubkey_hex, c in contacts.items(): + if not isinstance(c, dict): + continue + roster.append({ + "name": c.get("adv_name"), + "pubkey": c.get("public_key") or pubkey_hex, + "type": c.get("type"), + "last_advert": c.get("last_advert"), + "lat": c.get("adv_lat"), + "lon": c.get("adv_lon"), + "out_path_len": c.get("out_path_len"), + }) + return roster + + def self_info(self) -> dict: + """Companion self/connection status. {connected: False} if not connected.""" + if self._mc is None or not self._connected: + return {"connected": False} + info = self._self_info or {} + return { + "name": info.get("name"), + "pubkey": info.get("public_key"), + "connected": True, + "host": getattr(self.config, "meshcore_host", "100.64.0.9"), + "port": getattr(self.config, "meshcore_port", 5050), + "channel_count": len(self.known_channels()), + "last_advert_sent": self._last_advert_sent, + } + def set_context_config(self, cfg) -> None: """Set (or clear) the MeshCore passive-context filter config. @@ -177,6 +223,75 @@ class MeshCoreTransport(MeshTransport): """ self._mc_context = cfg + # ------------------------------------------------------------------ + # Self-advertisement + # ------------------------------------------------------------------ + + def send_advert(self) -> bool: + """Broadcast a signed self-advertisement to the mesh (flood=True). + + Bridges the async ``mc.commands.send_advert`` call to the dedicated + event loop via ``_run_coro``. Safe no-op returning False when not + connected or when the lib command raises. + + Callers must log the human-readable context (manual / on-connect); + this method is intentionally silent on success to avoid duplicate + log lines across call sites. + """ + if self._mc is None or not self._connected: + logger.debug("MeshCore: send_advert skipped — not connected") + return False + try: + self._run_coro(self._mc.commands.send_advert(flood=True)) + self._last_advert_sent = _time.time() + return True + except Exception as exc: + logger.warning("MeshCore: send_advert failed: %s", exc) + return False + + async def _periodic_advert_loop(self, interval: int) -> None: + """Periodic self-advertisement coroutine (runs as a Task on the dedicated loop). + + Sleeps *interval* seconds, sends one flood advert, repeats. Stops on + CancelledError (raised by ``_cancel_periodic_advert`` at disconnect) or + when the transport drops its connection. No overlap is possible because + the loop awaits the sleep before each send. + """ + try: + while True: + await asyncio.sleep(interval) + if not self._connected or self._mc is None: + return + try: + await self._mc.commands.send_advert(flood=True) + self._last_advert_sent = _time.time() + logger.info("MeshCore: sent periodic self-advert") + except Exception as exc: + logger.warning("MeshCore: periodic send_advert failed: %s", exc) + except asyncio.CancelledError: + logger.debug("MeshCore: periodic advert task cancelled") + raise + + def _schedule_periodic_advert(self, interval: int) -> None: + """Create the periodic advert asyncio.Task on the dedicated loop (thread-safe). + + Called from the main thread after connect(); the Task is created ON the + dedicated loop via call_soon_threadsafe so asyncio.create_task() fires + in the right context. + """ + def _arm() -> None: + self._advert_task = asyncio.get_event_loop().create_task( + self._periodic_advert_loop(interval) + ) + self._loop.call_soon_threadsafe(_arm) + + def _cancel_periodic_advert(self) -> None: + """Cancel the periodic advert task (thread-safe). Called at disconnect.""" + task = self._advert_task + self._advert_task = None + if task is not None and self._loop is not None and self._loop.is_running(): + self._loop.call_soon_threadsafe(task.cancel) + # ------------------------------------------------------------------ # Internal coroutines (run on the dedicated loop) # ------------------------------------------------------------------ @@ -269,6 +384,20 @@ class MeshCoreTransport(MeshTransport): # per-family broadcasts can resolve their channel name to a slot. self._enumerate_channels() + # Announce ourselves so other nodes can discover and DM us. + try: + if self.send_advert(): + logger.info("MeshCore: sent self-advert on connect") + else: + logger.warning("MeshCore: send_advert on connect returned False") + except Exception as exc: + logger.warning("MeshCore: send_advert on connect error: %s", exc) + + # Arm periodic re-advertisement if configured (default 3 h; 0 = disabled). + interval = getattr(self.config, "meshcore_advert_interval_seconds", 10800) + if interval > 0: + self._schedule_periodic_advert(interval) + logger.info( "MeshCoreTransport: connected as %s (pubkey %s)", self._self_info.get("name", "unknown"), @@ -277,6 +406,8 @@ class MeshCoreTransport(MeshTransport): def disconnect(self) -> None: """Disconnect and stop the event loop thread.""" + # Cancel periodic advert before tearing down the loop. + self._cancel_periodic_advert() if self._mc is not None: try: self._run_coro(self._do_disconnect(), timeout=10.0) diff --git a/work/tests/test_mesh_send_api.py b/work/tests/test_mesh_send_api.py index 84d290c..78fafb0 100644 --- a/work/tests/test_mesh_send_api.py +++ b/work/tests/test_mesh_send_api.py @@ -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 diff --git a/work/tests/test_meshcore_transport.py b/work/tests/test_meshcore_transport.py index 857486b..36b2d2e 100644 --- a/work/tests/test_meshcore_transport.py +++ b/work/tests/test_meshcore_transport.py @@ -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"