Merge pull request #925 from CyberMind-FR/feat/meshtastic-usage

meshtastic usage: local node + messaging + channel join link/QR (0.1.5)
This commit is contained in:
CyberMind 2026-07-29 09:54:28 +02:00 committed by GitHub
commit c3f6d82eb4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 259 additions and 23 deletions

View File

@ -5,11 +5,12 @@ from __future__ import annotations
import json
import logging
import subprocess
import sys
import threading
import time
from pathlib import Path
from .model import MeshState, parse_packet
from .model import MeshState, parse_packet, _nid
log = logging.getLogger("secubox.meshtastic.daemon")
@ -25,6 +26,30 @@ class Engine:
self.capture, self.bridge, self.clock = capture, bridge, clock
self.state = MeshState()
self.present = radio is not None
self.my_num = None
def seed_from_radio(self, radio) -> None:
"""Seed mesh state from the device's node DB at connect, so the panel
shows the local node (+ any already-known peers) immediately instead of
staying empty until fresh packets arrive."""
now = self.clock()
self.my_num = getattr(radio, "my_num", lambda: None)()
for info in getattr(radio, "node_db", lambda: [])():
self.state.apply_nodeinfo(info, now)
self.cache.update(self.snapshot())
def on_node(self, info: dict) -> None:
"""Live NODEINFO update from the device ('node' event)."""
self.state.apply_nodeinfo(info or {}, self.clock())
self.cache.update(self.snapshot())
def record_sent(self, channel: int, text: str) -> None:
"""Record our own outbound text so the panel shows sent messages
(they never come back through on_receive)."""
frm = _nid(self.my_num) if self.my_num else "!self"
self.state.add_message(channel, {"from": frm, "text": text,
"ts": self.clock(), "outbound": True})
self.cache.update(self.snapshot())
def channel_name(self, idx: int) -> str:
if 0 <= idx < len(self.cfg.channels):
@ -152,6 +177,22 @@ def _ctl_cb(verb: str, **kwargs) -> dict:
return {"status": "error", "stderr": (proc.stderr or proc.stdout).strip()[:300]}
def _qr_svg(text: str):
"""Offline QR as inline SVG markup (segno, pure-python, optional). Inline
SVG (not a data: URI) keeps it CSP-safe for the panel. Returns None if segno
isn't installed — the caller degrades to url-only."""
try:
import io
import segno
buf = io.BytesIO() # segno's SVG writer emits bytes
segno.make(text, error="m").save(
buf, kind="svg", scale=4, border=2,
dark="#0a1f14", light="#c9f5e0", xmldecl=False, svgns=True)
return buf.getvalue().decode("utf-8")
except Exception:
return None
def main() -> None:
logging.basicConfig(level=logging.INFO)
@ -178,6 +219,8 @@ def main() -> None:
if radio is not None:
radio.on("receive", engine.on_receive)
radio.on("node", engine.on_node)
engine.seed_from_radio(radio)
stop = threading.Event()
@ -192,9 +235,25 @@ def main() -> None:
if engine.radio is None:
return {"status": "radio-absent"}
engine.radio.send_text(text, channel)
engine.record_sent(channel, text)
return {"status": "sent", "channel": channel}
app = web.create_app(engine.cache, send_cb, _ctl_cb)
def channel_url_cb() -> dict:
if engine.radio is None:
return {"status": "radio-absent"}
url = getattr(engine.radio, "channel_url", lambda: None)()
out = {"url": url}
# Render an offline QR (inline SVG) so a phone can scan it to JOIN
# the mesh. segno is pure-python and optional — degrade to url-only.
if url:
out["qr_svg"] = _qr_svg(url)
# The URL carries the channel key — record the disclosure (journald
# is this non-root daemon's durable audit trail).
print("[meshtastic] channel-url (join link) disclosed",
file=sys.stderr, flush=True)
return out
app = web.create_app(engine.cache, send_cb, _ctl_cb, channel_url_cb)
cache.start_refresh(engine.snapshot, CACHE_REFRESH_INTERVAL, stop)
try:
uvicorn.run(app, uds=SOCKET_PATH, log_level="warning")

View File

@ -51,6 +51,7 @@ class Node:
snr: float | None = None
first_heard: float = 0.0
last_heard: float = 0.0
is_self: bool = False
class MeshState:
@ -91,6 +92,23 @@ class MeshState:
n.short = u.get("shortName", n.short)
n.long = u.get("longName", n.long)
n.role = u.get("role", n.role)
# A seeded device node-DB entry also carries position / battery / snr —
# fold them in so seeded nodes are as rich as packet-heard ones.
pos = info.get("position") or {}
lat, lon = pos.get("latitude"), pos.get("longitude")
if lat is not None and lon is not None:
n.pos = (lat, lon)
batt = (info.get("deviceMetrics") or {}).get("batteryLevel")
if batt is not None:
n.battery = batt
if info.get("snr") is not None:
n.snr = info.get("snr")
if info.get("is_self"):
n.is_self = True
def add_message(self, channel: int, entry: dict) -> None:
"""Record a message (inbound OR our own outbound) on a channel."""
self.messages.setdefault(int(channel), []).append(entry)
def to_dict(self) -> dict:
return {

View File

@ -19,9 +19,20 @@ class RadioInterface(Protocol):
class MockRadio:
def __init__(self) -> None:
def __init__(self, node_db=None, my_num=None) -> None:
self._cbs: dict[str, list[Callable]] = {}
self.sent: list[tuple[str, int]] = []
self._node_db = list(node_db or [])
self._my_num = my_num
def node_db(self) -> list[dict]:
return list(self._node_db)
def my_num(self):
return self._my_num
def channel_url(self, include_all: bool = True):
return "https://meshtastic.org/e/#MOCKCHANNELURL"
def on(self, event: str, cb: Callable) -> None:
self._cbs.setdefault(event, []).append(cb)
@ -101,6 +112,44 @@ class _SerialRadio:
def send_text(self, text: str, channel: int = 0) -> None:
self._iface.sendText(text, channelIndex=channel)
def my_num(self):
try:
return self._iface.myInfo.my_node_num
except Exception:
return None
def channel_url(self, include_all: bool = True):
"""The device's sharable channel-set URL (name + PSK + LoRa config) —
scan/open it on another device to JOIN this mesh."""
try:
return self._iface.localNode.getURL(includeAll=include_all)
except Exception:
return None
def node_db(self) -> list[dict]:
"""Snapshot the device's node DB (self + every node it already knows).
Lets the panel show the local node + known peers immediately on connect,
instead of staying empty until fresh packets arrive."""
out: list[dict] = []
try:
my = self.my_num()
for val in (getattr(self._iface, "nodes", None) or {}).values():
u = val.get("user") or {}
num = val.get("num")
out.append({
"num": num,
"user": {"shortName": u.get("shortName", ""),
"longName": u.get("longName", ""),
"role": u.get("role", "")},
"position": val.get("position") or {},
"deviceMetrics": val.get("deviceMetrics") or {},
"snr": val.get("snr"),
"is_self": num is not None and num == my,
})
except Exception:
pass
return out
def close(self) -> None:
try: self._iface.close()
except Exception: pass

View File

@ -72,6 +72,7 @@ def create_app(
cache: Any,
send_cb: Callable[[int, str], dict],
ctl_cb: Callable[..., dict],
channel_url_cb: Callable[[], dict] | None = None,
) -> FastAPI:
app = FastAPI(
title="SecuBox Meshtastic API",
@ -106,6 +107,15 @@ def create_app(
async def send_message(body: SendBody, _claims=Depends(require_jwt)):
return send_cb(body.channel, body.text)
@app.get(f"{PREFIX}/channel-url")
async def get_channel_url(_claims=Depends(require_jwt)):
# Sharable Meshtastic channel URL (encodes name + PSK + LoRa config) so
# another device / phone can JOIN this mesh. It reveals the channel key
# by design — JWT-gated, and the daemon audit-logs each disclosure.
if channel_url_cb is None:
raise HTTPException(status_code=503, detail="radio absent")
return channel_url_cb()
@app.post(f"{PREFIX}/mode")
async def set_mode(body: ModeBody, _claims=Depends(require_jwt)):
# Refus STRUCTUREL avant tout ctl_cb (comme set_pin/set_lifecycle

View File

@ -1,22 +1,19 @@
secubox-meshtastic (0.1.4-1~bookworm1) bookworm; urgency=medium
secubox-meshtastic (0.1.5-1~bookworm1) bookworm; urgency=medium
* Fix "radio absent" despite a working radio (#918). Root cause was NOT the
radio (the RAK4631 opens + handshakes fine): the leaf dirs
/var/cache/secubox/meshtastic and /var/log/secubox/meshtastic had drifted to
owner `secubox` (not `secubox-meshtastic`), so the daemon could not write
state.json — the refresh loop swallowed the PermissionError and the panel
served a 2-day-stale cached `radio: absent`. postinst now ENFORCES leaf-dir
ownership on every configure (install -d only sets it on create); leaf dirs
only, never the shared parents.
* Fix config-permission crash-loop: /etc/secubox/meshtastic.toml could be left
root:root (group-unreadable) → ConfigError → the unit crash-loops (dies on
reboot). postinst now chgrp+g+r on every configure.
* radio.py: open the serial with a bounded retry (release the orphaned pyserial
FD via gc between attempts) and log the open lifecycle to stderr — the
logging module is unusable here because meshtastic reconfigures the root
logger on import, which had made a real failure invisible.
* cache.py: the refresh loop no longer swallows write errors silently — it logs
them to stderr/journald so a frozen cache is diagnosable.
* Usability: the panel is no longer empty when the radio is present.
- seed the mesh state from the device node DB at connect (local node +
already-known peers) and subscribe to live 'node' events, so /nodes shows
the local node (is_self) immediately instead of waiting for packets.
- record our own outbound text messages (POST /send) into the state so
/messages shows sent messages (they never come back via on_receive).
- Node model gains is_self + seeds position/battery/snr from the node DB.
* Channel join link ("lien") + QR: new GET /api/v1/meshtastic/channel-url
returns the device's sharable Meshtastic channel URL (name + PSK + LoRa
config, from localNode.getURL) plus an offline inline-SVG QR (segno, in
Recommends) — scan/open it to JOIN the mesh from a phone/second node. The
URL discloses the channel key, so it is JWT-gated and the daemon logs each
disclosure. Panel: a "Générer le lien de jonction" button on the Channels
tab shows the URL + QR.
-- Gerald KERMA <devel@cybermind.fr> Wed, 29 Jul 2026 07:35:00 +0200
-- Gerald KERMA <devel@cybermind.fr> Wed, 29 Jul 2026 08:10:00 +0200

View File

@ -20,7 +20,7 @@ Depends: ${misc:Depends},
python3-paho-mqtt,
secubox-core,
sudo
Recommends: mosquitto
Recommends: mosquitto, python3-segno
Description: SecuBox-Deb Meshtastic LoRa node integration
Native Python module for managing Meshtastic LoRa mesh nodes: radio/cache/
passive-listener/MQTT-bridge daemon (secubox-meshtasticd), privileged CLI

View File

@ -39,3 +39,37 @@ def test_active_only_mode_skips_passive(tmp_path):
def test_snapshot_reports_radio_present(tmp_path):
eng,_,_ = _engine(tmp_path)
assert eng.snapshot()["radio"] == "present"
def test_seed_from_radio_populates_local_node(tmp_path):
from api.bridge import Bridge
from api.daemon import Engine
cfg = Config(mode="both", region="EU_868", channels=[ChannelCfg("fam", ("off",), "fam-psk")],
shared_grid=BrokerCfg("10.10.0.1:1883"))
br = Bridge(cfg, lambda k: FakeMqtt()); br.start()
cap = PassiveCapture(tmp_path/"p.jsonl")
radio = MockRadio(node_db=[
{"num": 0x11, "user": {"shortName": "ME", "longName": "My Node", "role": "CLIENT"}, "is_self": True},
{"num": 0x22, "user": {"shortName": "PE", "longName": "Peer"},
"position": {"latitude": 45.1, "longitude": 6.2}},
], my_num=0x11)
eng = Engine(cfg, radio, StateCache(tmp_path/"s.json"), cap, br, clock=lambda: 42.0)
eng.seed_from_radio(radio)
ids = {n["id"]: n for n in eng.snapshot()["nodes"]}
assert eng.my_num == 0x11
assert ids["!00000011"]["is_self"] is True
assert ids["!00000011"]["long"] == "My Node"
assert tuple(ids["!00000022"]["pos"]) == (45.1, 6.2)
def test_record_sent_appears_in_messages(tmp_path):
eng, _, _ = _engine(tmp_path)
eng.my_num = 0x11
eng.record_sent(0, "hello mesh")
msgs = eng.snapshot()["messages_by_channel"]["0"]
assert msgs[-1]["text"] == "hello mesh"
assert msgs[-1]["outbound"] is True
assert msgs[-1]["from"] == "!00000011"
def test_on_node_updates_live(tmp_path):
eng, _, _ = _engine(tmp_path)
eng.on_node({"num": 0x33, "user": {"shortName": "LV", "longName": "Live"}})
assert "!00000033" in {n["id"] for n in eng.snapshot()["nodes"]}

View File

@ -196,6 +196,37 @@ def test_grid_good_value_delegates_to_ctl(client, ctl_calls):
assert ctl_calls == [("set-grid", {"channel": "LongFast", "grid": ["off", "shared"]})]
# ---------------------------------------------------------------------------
# GET /channel-url — sharable join link (discloses the channel key)
# ---------------------------------------------------------------------------
def test_channel_url_requires_jwt_when_not_overridden():
app = web.create_app(FakeCache(dict(STATE)), lambda c, t: {}, lambda v, **kw: {},
lambda: {"url": "x"})
c = TestClient(app)
assert c.get("/api/v1/meshtastic/channel-url").status_code == 401
def test_channel_url_returns_url_from_cb():
app = web.create_app(FakeCache(dict(STATE)), lambda c, t: {}, lambda v, **kw: {},
lambda: {"url": "https://meshtastic.org/e/#ABC"})
c = TestClient(app)
c.app.dependency_overrides[web.require_jwt] = _noop_jwt
resp = c.get("/api/v1/meshtastic/channel-url")
assert resp.status_code == 200
assert resp.json() == {"url": "https://meshtastic.org/e/#ABC"}
c.app.dependency_overrides.clear()
def test_channel_url_503_when_no_cb():
# No channel_url_cb wired (radio absent at wiring time) -> 503, not 500.
app = web.create_app(FakeCache(dict(STATE)), lambda c, t: {}, lambda v, **kw: {})
c = TestClient(app)
c.app.dependency_overrides[web.require_jwt] = _noop_jwt
assert c.get("/api/v1/meshtastic/channel-url").status_code == 503
c.app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# no direct radio/systemctl/subprocess access from web.py
# ---------------------------------------------------------------------------

View File

@ -208,6 +208,17 @@
<div id="tab-channels" class="tab-content">
<p class="note" style="color:var(--p31-dim);font-size:0.7rem;margin-bottom:0.75rem">Toggle which grids each configured channel bridges to (off / shared / on), then Save. Changes are delegated to the privileged ctl (never applied in-process).</p>
<div id="channelList"><div class="empty-note">Loading…</div></div>
<h3 style="margin:1.4rem 0 0.4rem">🔗 Lien de partage du mesh</h3>
<p class="note" style="color:var(--p31-dim);font-size:0.7rem;margin-bottom:0.6rem">Ouvre ce lien (ou scanne le QR) dans l'app Meshtastic pour <strong>rejoindre ce mesh</strong>. ⚠ Il encode la clé du canal — ne le partage qu'avec des pairs de confiance (chaque génération est journalisée).</p>
<div class="btn-group">
<button class="btn primary" id="shareBtn">🔗 Générer le lien de jonction</button>
<button class="btn" id="shareCopyBtn" style="display:none">📋 Copier</button>
</div>
<div id="shareUrlBox" style="display:none;margin-top:0.7rem">
<input type="text" id="shareUrl" readonly style="width:100%;font-size:0.62rem;padding:0.35rem;background:var(--panel);color:var(--cyan);border:1px solid var(--border)">
<div id="shareQr" style="margin-top:0.7rem;max-width:220px;background:#c9f5e0;padding:0.4rem;border-radius:4px;display:inline-block"></div>
</div>
</div>
<!-- Sniffer tab -->
@ -500,6 +511,33 @@
if (e.key === 'Enter') sendMessage();
});
// --- Channel share link + QR (the "lien" to join the mesh) --------------
async function loadShareLink() {
const btn = document.getElementById('shareBtn');
btn.disabled = true; btn.textContent = '⏳ Génération…';
try {
const d = await api('/channel-url');
if (!d || !d.url) { toast('Lien indisponible (radio absente ?)'); return; }
document.getElementById('shareUrl').value = d.url;
// Inline SVG QR (CSP-safe: no data: URI, no external resource).
document.getElementById('shareQr').innerHTML = d.qr_svg || '';
document.getElementById('shareUrlBox').style.display = 'block';
document.getElementById('shareCopyBtn').style.display = '';
} catch (e) {
toast('Erreur: ' + (e && e.message ? e.message : e));
} finally {
btn.disabled = false; btn.textContent = '🔗 Générer le lien de jonction';
}
}
document.getElementById('shareBtn').addEventListener('click', loadShareLink);
document.getElementById('shareCopyBtn').addEventListener('click', () => {
const u = document.getElementById('shareUrl');
u.select();
if (navigator.clipboard) navigator.clipboard.writeText(u.value).then(() => toast('Lien copié'));
else { document.execCommand('copy'); toast('Lien copié'); }
});
// --- Channels tab (grid policy) ----------------------------------------
const GRID_VALUES = ['off', 'shared', 'on'];