mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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.
This commit is contained in:
parent
ef16bbcfcd
commit
4138ebfdc3
8 changed files with 538 additions and 2 deletions
|
|
@ -512,6 +512,7 @@ export interface MeshcoreSelf {
|
|||
host?: string
|
||||
port?: number
|
||||
channel_count?: number
|
||||
last_advert_sent?: number | null // epoch seconds; null/absent = never advertised
|
||||
}
|
||||
|
||||
export async function fetchMeshcoreContacts(): Promise<MeshcoreContacts> {
|
||||
|
|
@ -521,6 +522,18 @@ export async function fetchMeshcoreSelf(): Promise<MeshcoreSelf> {
|
|||
return fetchJson<MeshcoreSelf>('/api/meshcore/self')
|
||||
}
|
||||
|
||||
export async function sendMeshcoreAdvert(): Promise<TestSendResult> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,18 +1,43 @@
|
|||
import { useState, 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<MeshcoreSelf | null>(null)
|
||||
const [channels, setChannels] = useState<MeshcoreChannels | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Send Advert state
|
||||
const [advertSending, setAdvertSending] = useState(false)
|
||||
const [advertResult, setAdvertResult] = useState<TestSendResult | null>(null)
|
||||
|
||||
// Auto-advert control state — interval in hours (0 = disabled)
|
||||
// Loaded from connection config; editable in-page and PUTted back.
|
||||
const [advertIntervalHours, setAdvertIntervalHours] = useState<number>(3)
|
||||
const [advertIntervalSaving, setAdvertIntervalSaving] = useState(false)
|
||||
const [advertIntervalSaved, setAdvertIntervalSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'Companion & Channels - MeshAI'
|
||||
}, [])
|
||||
|
|
@ -42,6 +67,64 @@ export default function MeshCoreCompanion() {
|
|||
}
|
||||
}, [])
|
||||
|
||||
// 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<string, unknown>
|
||||
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 : []
|
||||
|
||||
|
|
@ -100,7 +183,37 @@ export default function MeshCoreCompanion() {
|
|||
<dt className="text-[#777] mb-1">Channels joined</dt>
|
||||
<dd className="text-slate-100">{self?.channel_count ?? 0}</dd>
|
||||
</div>
|
||||
{self?.last_advert_sent != null && (
|
||||
<div>
|
||||
<dt className="text-[#777] mb-1">Last advertised</dt>
|
||||
<dd className="text-slate-100">{relativeTime(self.last_advert_sent)}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{/* Send Advert */}
|
||||
<div className="pt-2 border-t border-border space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleSendAdvert}
|
||||
disabled={advertSending}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-accent/10 hover:bg-accent/20 text-accent border border-accent/30 rounded disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Radio size={14} />
|
||||
{advertSending ? 'Sending…' : 'Send Advert'}
|
||||
</button>
|
||||
{advertResult != null && (
|
||||
<span
|
||||
className={`text-sm ${advertResult.sent ? 'text-green-400' : 'text-red-400'}`}
|
||||
>
|
||||
{advertResult.sent ? 'Advert sent' : advertResult.detail}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-[#555]">
|
||||
Announce this node to the mesh so others can discover and DM it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
|
|
@ -116,6 +229,45 @@ export default function MeshCoreCompanion() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Advertising settings */}
|
||||
<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">Advertising</h3>
|
||||
</div>
|
||||
<div className="px-4 py-4 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-[#777] uppercase tracking-wide">
|
||||
Auto-advert interval
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={advertIntervalHours}
|
||||
onChange={(e) => setAdvertIntervalHours(Number(e.target.value))}
|
||||
className="bg-[#0a0e17] border border-[#1e2a3a] text-slate-200 text-sm rounded px-2 py-1.5 focus:outline-none focus:border-accent"
|
||||
>
|
||||
<option value={0}>Disabled</option>
|
||||
<option value={1}>Every 1 hour</option>
|
||||
<option value={3}>Every 3 hours (default)</option>
|
||||
<option value={6}>Every 6 hours</option>
|
||||
<option value={12}>Every 12 hours</option>
|
||||
<option value={24}>Every 24 hours</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleSaveAdvertInterval}
|
||||
disabled={advertIntervalSaving}
|
||||
className="px-3 py-1.5 text-sm bg-accent/10 hover:bg-accent/20 text-accent border border-accent/30 rounded disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{advertIntervalSaving ? 'Saving…' : advertIntervalSaved ? 'Saved' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-[#555]">
|
||||
AIDA sends a flood advertisement at this interval so it stays discoverable.
|
||||
Stored in <code className="text-accent/80">connection.meshcore_advert_interval_seconds</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel list */}
|
||||
<div className="bg-bg-card border border-border">
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -66,6 +66,27 @@ async def meshcore_self(request: Request):
|
|||
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]
|
||||
|
|
|
|||
|
|
@ -102,6 +102,11 @@ class CompositeTransport(MeshTransport):
|
|||
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)
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -208,6 +213,7 @@ class MeshCoreTransport(MeshTransport):
|
|||
"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:
|
||||
|
|
@ -217,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)
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -309,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"),
|
||||
|
|
@ -317,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)
|
||||
|
|
|
|||
|
|
@ -262,3 +262,91 @@ def test_meshcore_self_disconnected():
|
|||
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
|
||||
|
||||
|
|
@ -629,3 +634,163 @@ class TestSelfInfo:
|
|||
"""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