mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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>
This commit is contained in:
parent
61ca0057e2
commit
ef16bbcfcd
8 changed files with 605 additions and 41 deletions
|
|
@ -492,6 +492,35 @@ export async function getMeshcoreChannels(): Promise<MeshcoreChannels> {
|
|||
return fetchJson<MeshcoreChannels>('/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
|
||||
}
|
||||
|
||||
export async function fetchMeshcoreContacts(): Promise<MeshcoreContacts> {
|
||||
return fetchJson<MeshcoreContacts>('/api/meshcore/contacts')
|
||||
}
|
||||
export async function fetchMeshcoreSelf(): Promise<MeshcoreSelf> {
|
||||
return fetchJson<MeshcoreSelf>('/api/meshcore/self')
|
||||
}
|
||||
|
||||
export async function sendTestMessage(body: {
|
||||
transport: 'meshtastic' | 'meshcore'
|
||||
channel: string | number
|
||||
|
|
|
|||
|
|
@ -1,34 +1,140 @@
|
|||
import { useEffect } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Bot } from 'lucide-react'
|
||||
import {
|
||||
fetchMeshcoreSelf,
|
||||
getMeshcoreChannels,
|
||||
type MeshcoreSelf,
|
||||
type MeshcoreChannels,
|
||||
} from '../lib/api'
|
||||
|
||||
export default function MeshCoreCompanion() {
|
||||
const [self, setSelf] = useState<MeshcoreSelf | null>(null)
|
||||
const [channels, setChannels] = useState<MeshcoreChannels | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
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
|
||||
}
|
||||
}, [])
|
||||
|
||||
const connected = self?.connected === true
|
||||
const channelNames = channels?.active ? channels.channels : []
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="bg-bg-card border border-border p-8">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-[#0a0e17] border border-[#1e2a3a] flex items-center justify-center">
|
||||
<Bot size={24} className="text-accent" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-xl font-semibold text-slate-100">Companion & Channels</h2>
|
||||
<span className="px-2 py-0.5 text-[10px] uppercase tracking-wide rounded bg-slate-700 text-slate-300">
|
||||
Coming soon
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 leading-relaxed max-w-prose">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-[#0a0e17] border border-[#1e2a3a] flex items-center justify-center">
|
||||
<Bot size={24} className="text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-100">Companion & Channels</h2>
|
||||
<p className="text-sm text-[#777]">
|
||||
Live status for the AIDA MeshCore companion and its joined channels.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="text-slate-400">Loading...</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-3 text-sm bg-red-500/10 text-red-400 border border-red-500/20">
|
||||
{error}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Status card */}
|
||||
<div className="bg-bg-card border border-border p-6">
|
||||
{connected ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-green-500" />
|
||||
<span className="text-sm font-medium text-green-400">Connected</span>
|
||||
</div>
|
||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-4 text-sm">
|
||||
<div>
|
||||
<dt className="text-[#777] mb-1">Node name</dt>
|
||||
<dd className="text-slate-100">{self?.name ?? 'unnamed'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[#777] mb-1">Host</dt>
|
||||
<dd className="text-slate-100 font-mono">
|
||||
{self?.host ?? '—'}
|
||||
{self?.port != null ? `:${self.port}` : ''}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-[#777] mb-1">Public key</dt>
|
||||
<dd className="text-slate-100 font-mono text-xs break-all">
|
||||
{self?.pubkey ?? '—'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[#777] mb-1">Channels joined</dt>
|
||||
<dd className="text-slate-100">{self?.channel_count ?? 0}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-slate-600" />
|
||||
<span className="text-sm font-medium text-slate-400">Not connected</span>
|
||||
</div>
|
||||
<p className="text-sm text-[#777] leading-relaxed max-w-prose">
|
||||
The MeshCore companion is offline or inactive. No node identity or channel
|
||||
membership is available while the companion is disconnected.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Channel list */}
|
||||
<div className="bg-bg-card border border-border">
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<h3 className="text-sm font-medium text-slate-200">Channels</h3>
|
||||
</div>
|
||||
{channelNames.length > 0 ? (
|
||||
<ul className="divide-y divide-border">
|
||||
{channelNames.map((name) => (
|
||||
<li key={name} className="px-4 py-2.5 text-sm text-slate-200 font-mono">
|
||||
{name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="px-4 py-3 text-sm text-[#777]">No channels</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, { label: string; className: string }> = {
|
||||
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 (
|
||||
<span className={`px-2 py-0.5 text-[10px] uppercase tracking-wide rounded ${meta.className}`}>
|
||||
{meta.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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<MeshcoreContacts | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="bg-bg-card border border-border p-8">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-[#0a0e17] border border-[#1e2a3a] flex items-center justify-center">
|
||||
<Users size={24} className="text-accent" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-xl font-semibold text-slate-100">MeshCore Contacts</h2>
|
||||
<span className="px-2 py-0.5 text-[10px] uppercase tracking-wide rounded bg-slate-700 text-slate-300">
|
||||
Coming soon
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 leading-relaxed max-w-prose">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-w-4xl mx-auto space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-[#0a0e17] border border-[#1e2a3a] flex items-center justify-center">
|
||||
<Users size={24} className="text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-100">MeshCore Contacts</h2>
|
||||
<p className="text-sm text-[#777]">
|
||||
The companion's known contact roster — names, types, and last-heard times.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="text-slate-400">Loading...</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-3 text-sm bg-red-500/10 text-red-400 border border-red-500/20">
|
||||
{error}
|
||||
</div>
|
||||
) : data && data.active === false ? (
|
||||
<div className="bg-bg-card border border-border p-6">
|
||||
<p className="text-sm text-[#777] leading-relaxed max-w-prose">
|
||||
The MeshCore companion is not connected. The contact roster is unavailable until the
|
||||
companion comes online.
|
||||
</p>
|
||||
</div>
|
||||
) : data && data.contacts.length === 0 ? (
|
||||
<div className="bg-bg-card border border-border p-6">
|
||||
<p className="text-sm text-[#777] leading-relaxed max-w-prose">
|
||||
No contacts yet. The companion is connected but has not discovered any nodes so far.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-bg-card border border-border overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-[#777]">
|
||||
<th className="px-4 py-2.5 font-medium">Name</th>
|
||||
<th className="px-4 py-2.5 font-medium">Type</th>
|
||||
<th className="px-4 py-2.5 font-medium">Last heard</th>
|
||||
<th className="px-4 py-2.5 font-medium">Position</th>
|
||||
<th className="px-4 py-2.5 font-medium">Pubkey</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{(data?.contacts ?? []).map((c) => (
|
||||
<tr key={c.pubkey} className="hover:bg-bg-hover">
|
||||
<td className="px-4 py-2.5 text-slate-100">{contactName(c)}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<TypeBadge type={c.type} />
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-slate-300">{relativeTime(c.last_advert)}</td>
|
||||
<td className="px-4 py-2.5 text-slate-300 font-mono text-xs">{position(c)}</td>
|
||||
<td className="px-4 py-2.5 text-slate-400 font-mono text-xs">
|
||||
{shortPubkey(c.pubkey)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-[#777]">
|
||||
Telemetry auto-poll is coming in the next pass.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,33 @@ 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}
|
||||
|
||||
|
||||
class TestSendRequest(BaseModel):
|
||||
transport: str
|
||||
channel: Union[str, int]
|
||||
|
|
|
|||
|
|
@ -92,6 +92,16 @@ 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}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Routing decision helpers (factored out for unit-test access)
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -170,6 +170,46 @@ 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()),
|
||||
}
|
||||
|
||||
def set_context_config(self, cfg) -> None:
|
||||
"""Set (or clear) the MeshCore passive-context filter config.
|
||||
|
||||
|
|
|
|||
|
|
@ -157,3 +157,108 @@ 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}
|
||||
|
|
|
|||
|
|
@ -508,3 +508,124 @@ 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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue