mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 18:36:55 +00:00
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:
commit
c3f6d82eb4
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user