diff --git a/Bot_Usage.md b/Bot_Usage.md index 01935cc..bf68e00 100644 --- a/Bot_Usage.md +++ b/Bot_Usage.md @@ -1097,7 +1097,27 @@ the listed channels. ``` !adm radio pathhash show the radio's outgoing path-hash width !adm radio pathhash <1|2|3> set it (bytes per hop; mode = bytes-1) -``` +!adm radio name set the node's advertised name +!adm radio set freq=.. bw=.. sf=.. cr=.. [tx=..] set radio params +!adm radio preset [region] list, or apply a region preset (freq/bw/sf/cr) +!adm radio location set the node's coordinates +!adm radio advloc include the node's location in adverts +!adm radio reboot reboot the radio (the bot reconnects) +!adm radio key <128-hex> import a private key — CHANGES the node identity +``` + +These mirror the web **Manage → Radio** page (Identity + Radio Settings), go +through the same audited management layer, and refresh the cached device info +after applying. Notes: +- `set` requires `freq`, `bw`, `sf` and `cr` together (the firmware sets them + atomically); `tx` may be given alone. Wrong values can isolate the node, and + radio-param changes usually need a `reboot` to take effect. +- `preset` fills the region defaults; US/Canada and EU868 match MeshCore's + documented values, 433/ANZ are community defaults — verify for your area. +- `key` is **destructive**: the node gets a new pubkey, so existing contacts + must re-add the bot and old DMs stop decrypting. The bot rewrites its cached + key file and adopts the new identity; a `reboot` is recommended. `!adm` is + DM-only and owner-gated, which is the access control for this. IMPORTANT: a radio's path-hash width only governs the encoding of packets **that radio originates**. The width of a *received* packet's path is set diff --git a/commands/adm.py b/commands/adm.py index cd9215f..9ec22af 100644 --- a/commands/adm.py +++ b/commands/adm.py @@ -18,7 +18,7 @@ import json import time -from management import Management, MgmtError +from management import RADIO_PRESETS, Management, MgmtError NAME = "adm" TRIGGERS = ["!adm"] @@ -63,6 +63,13 @@ ("command delay ", "delay before sending replies (0 off, 0.1–2.0)"), ("command retry ", "resend a channel reply if no repeat heard (0 off, max 5)"), ("radio pathhash [1|2|3]", "show/set this radio's outgoing path-hash width"), + ("radio name ", "set the node's advertised name"), + ("radio set freq=.. bw=.. sf=.. cr=.. [tx=..]", "set radio params (freq/bw/sf/cr together)"), + ("radio preset [region]", "list/apply a region preset (freq/bw/sf/cr)"), + ("radio location ", "set the node's coordinates"), + ("radio advloc ", "include the node's location in adverts"), + ("radio reboot", "reboot the radio"), + ("radio key <128-hex>", "import a private key — CHANGES the node's identity"), ("advert ", "send a flood or zero-hop advertisement"), ("advert interval ", "send a flood advert every N hours (0 disables)"), ("status", "bot health and runtime stats"), @@ -716,7 +723,8 @@ async def _command_retry(ctx, rest): async def _cmd_radio(ctx, rest): parts = rest.split(maxsplit=1) if not parts: - return "Usage: !adm radio ..." + return ("Usage: !adm radio ...") op = parts[0].lower() args = parts[1].strip() if len(parts) > 1 else "" handler = _RADIO_OPS.get(op) @@ -767,8 +775,114 @@ async def _radio_pathhash(ctx, args): return f"out path-hash set to {want_bytes} byte(s)/hop (mode {mode})." +async def _radio_name(ctx, args): + if not args.strip(): + return "Usage: !adm radio name " + try: + r = await ctx.bot.mgmt.radio_set_name(args.strip(), **_actor(ctx)) + except MgmtError as e: + return e.message + return f"radio name set to {r['name']!r}" + + +async def _radio_set(ctx, args): + # !adm radio set freq=910.525 bw=62.5 sf=7 cr=5 tx=22 + fields = {} + aliases = {"freq": "freq", "bw": "bw", "sf": "sf", "cr": "cr", + "tx": "tx_power", "txpower": "tx_power", "tx_power": "tx_power"} + for tok in args.split(): + if "=" not in tok: + return "Usage: !adm radio set freq=.. bw=.. sf=.. cr=.. [tx=..]" + k, v = tok.split("=", 1) + dest = aliases.get(k.strip().lower()) + if not dest: + return f"unknown field {k!r} (freq, bw, sf, cr, tx)" + fields[dest] = v.strip() + if not fields: + return "Usage: !adm radio set freq=.. bw=.. sf=.. cr=.. [tx=..]" + try: + r = await ctx.bot.mgmt.radio_apply_settings(**fields, **_actor(ctx)) + except MgmtError as e: + return e.message + return "radio set: " + ", ".join(r["changed"]) + + +async def _radio_preset(ctx, args): + name = args.strip() + if not name: + return "Presets: " + " | ".join(RADIO_PRESETS) + match = next( + (k for k in RADIO_PRESETS + if k.lower() == name.lower() or name.lower() in k.lower()), + None, + ) + if not match: + return "Unknown preset. Options: " + " | ".join(RADIO_PRESETS) + p = RADIO_PRESETS[match] + try: + r = await ctx.bot.mgmt.radio_apply_settings( + freq=p["freq"], bw=p["bw"], sf=p["sf"], cr=p["cr"], **_actor(ctx) + ) + except MgmtError as e: + return e.message + return f"applied preset {match}: " + ", ".join(r["changed"]) + + +async def _radio_location(ctx, args): + p = args.split() + if len(p) < 2: + return "Usage: !adm radio location " + try: + r = await ctx.bot.mgmt.radio_apply_settings(lat=p[0], lon=p[1], **_actor(ctx)) + except MgmtError as e: + return e.message + return "radio set: " + ", ".join(r["changed"]) + + +async def _radio_advloc(ctx, args): + v = args.strip().lower() + if v not in ("on", "off", "true", "false", "1", "0"): + return "Usage: !adm radio advloc " + on = v in ("on", "true", "1") + try: + await ctx.bot.mgmt.radio_apply_settings(adv_loc_policy=on, **_actor(ctx)) + except MgmtError as e: + return e.message + return f"advert location sharing {'on' if on else 'off'}" + + +async def _radio_reboot(ctx, args): + try: + await ctx.bot.mgmt.radio_reboot(**_actor(ctx)) + except MgmtError as e: + return e.message + return "radio rebooting… (the bot will reconnect)" + + +async def _radio_key(ctx, args): + # DESTRUCTIVE: changes the node's identity (new pubkey). adm is DM-only and + # owner-authorized, which is the gate for this. + key = args.strip() + if not key: + return ("Usage: !adm radio key <128-hex private key> — WARNING: gives " + "the node a NEW identity; contacts must re-add the bot") + try: + r = await ctx.bot.mgmt.radio_apply_identity(private_key=key, **_actor(ctx)) + except MgmtError as e: + return e.message + return (f"new identity imported; pubkey={r['pubkey'][:16]}… — " + "reboot recommended (!adm radio reboot)") + + _RADIO_OPS = { "pathhash": _radio_pathhash, + "name": _radio_name, + "set": _radio_set, + "preset": _radio_preset, + "location": _radio_location, + "advloc": _radio_advloc, + "reboot": _radio_reboot, + "key": _radio_key, } diff --git a/management.py b/management.py index e2f3231..5775895 100644 --- a/management.py +++ b/management.py @@ -46,6 +46,31 @@ def _actor(actor_pubkey: Optional[str]) -> Optional[str]: return (actor_pubkey or "").lower() or None +# Convenience region presets for the radio config (web dropdown + '!adm radio +# preset'). Values just FILL the editable fields; the operator reviews before +# applying. US/Canada and EU868 match MeshCore's documented defaults; 433 and +# ANZ are common community values — verify for your area before applying. +RADIO_PRESETS = { + "US/Canada (915)": {"freq": 910.525, "bw": 62.5, "sf": 7, "cr": 5}, + "EU (868)": {"freq": 869.618, "bw": 62.5, "sf": 8, "cr": 5}, + "EU/Asia (433)": {"freq": 433.5, "bw": 62.5, "sf": 8, "cr": 5}, + "ANZ (915/919)": {"freq": 915.8, "bw": 62.5, "sf": 7, "cr": 5}, +} + + +def _radio_ok(ev, what: str): + """Raise MgmtError if a radio command returned ERROR; else pass it through.""" + t = getattr(getattr(ev, "type", None), "name", "") if ev else "" + if t == "ERROR": + p = ev.payload if isinstance(ev.payload, dict) else {} + reason = ( + p.get("reason") or p.get("code_string") + or p.get("error_code") or "rejected" + ) + raise MgmtError(f"radio rejected {what}: {reason}", "conflict") + return ev + + class Management: def __init__(self, bot): self.bot = bot @@ -835,6 +860,129 @@ async def radio_set_evict_policy( ) return self._evict_policy() + # ================================================================== + # Radio configuration (name, radio params, location, identity) + # ================================================================== + async def radio_set_name( + self, name, *, actor_pubkey=None, actor_name=None, + ) -> dict: + """Set the node's advertised name. Audited as 'radio.name'.""" + name = (name or "").strip() + if not name: + raise MgmtError("name is required", "invalid") + if len(name.encode("utf-8")) > 32: + raise MgmtError("name too long (max 32 bytes)", "invalid") + _radio_ok(await self.bot.mc.commands.set_name(name), "name change") + await self.bot.refresh_self_info() + await self._audit(actor_pubkey, actor_name, "radio.name", None, name) + return {"name": name} + + async def radio_apply_settings( + self, *, freq=None, bw=None, sf=None, cr=None, tx_power=None, + lat=None, lon=None, adv_loc_policy=None, reboot=False, + actor_pubkey=None, actor_name=None, + ) -> dict: + """Apply any supplied radio/location settings, refresh self-info, and + optionally reboot. freq/bw/sf/cr are atomic (set_radio takes all four), + so they must be supplied together. Audited as 'radio.settings'.""" + changed = [] + if any(v is not None for v in (freq, bw, sf, cr)): + if None in (freq, bw, sf, cr): + raise MgmtError("freq, bw, sf and cr must be set together", "invalid") + try: + freq, bw, sf, cr = float(freq), float(bw), int(sf), int(cr) + except (TypeError, ValueError): + raise MgmtError("invalid radio parameter", "invalid") + _radio_ok( + await self.bot.mc.commands.set_radio(freq, bw, sf, cr), "radio params" + ) + changed.append(f"freq={freq} bw={bw} sf={sf} cr={cr}") + if tx_power is not None: + try: + tx_power = int(tx_power) + except (TypeError, ValueError): + raise MgmtError("invalid tx_power", "invalid") + _radio_ok(await self.bot.mc.commands.set_tx_power(tx_power), "tx power") + changed.append(f"tx={tx_power}") + if lat is not None or lon is not None: + if lat is None or lon is None: + raise MgmtError("both lat and lon are required", "invalid") + try: + lat, lon = float(lat), float(lon) + except (TypeError, ValueError): + raise MgmtError("invalid coordinates", "invalid") + if not (-90 <= lat <= 90 and -180 <= lon <= 180): + raise MgmtError("coordinates out of range", "invalid") + _radio_ok(await self.bot.mc.commands.set_coords(lat, lon), "location") + changed.append(f"loc={lat},{lon}") + if adv_loc_policy is not None: + pol = 1 if adv_loc_policy else 0 + _radio_ok( + await self.bot.mc.commands.set_advert_loc_policy(pol), "location policy" + ) + changed.append(f"adv_loc={pol}") + if not changed: + raise MgmtError("no settings to change", "invalid") + await self.bot.refresh_self_info() + await self._audit( + actor_pubkey, actor_name, "radio.settings", None, " ".join(changed) + ) + if reboot: + await self.radio_reboot(actor_pubkey=actor_pubkey, actor_name=actor_name) + return {"changed": changed, "rebooted": bool(reboot)} + + async def radio_apply_identity( + self, *, name=None, private_key=None, reboot=False, + actor_pubkey=None, actor_name=None, + ) -> dict: + """Apply Identity-section changes: name and/or a new private key. The + key import changes the bot's identity — see _radio_import_key.""" + result = {"rebooted": False} + if name is not None and name != "": + await self.radio_set_name( + name, actor_pubkey=actor_pubkey, actor_name=actor_name + ) + result["name"] = name + if private_key: + result["pubkey"] = await self._radio_import_key( + private_key, actor_pubkey=actor_pubkey, actor_name=actor_name + ) + elif name in (None, ""): + raise MgmtError("nothing to change", "invalid") + if reboot: + await self.radio_reboot(actor_pubkey=actor_pubkey, actor_name=actor_name) + result["rebooted"] = True + return result + + async def _radio_import_key( + self, key_hex, *, actor_pubkey=None, actor_name=None, + ) -> str: + """Import a new 64-byte private key: the node gets a NEW pubkey. Existing + contacts can no longer DM the bot (shared secrets change) until they + re-add it. The bot adopts the new identity (rewrites its cached key file) + so a later restart doesn't reload the stale key. Audited 'radio.identity'.""" + s = (key_hex or "").strip().replace(" ", "") + try: + key = bytes.fromhex(s) + except ValueError: + raise MgmtError("private key must be hex", "invalid") + if len(key) != 64: + raise MgmtError("private key must be 64 bytes (128 hex chars)", "invalid") + _radio_ok(await self.bot.mc.commands.import_private_key(key), "key import") + pubkey = await self.bot.adopt_new_private_key(key) + await self.bot.refresh_self_info() + await self._audit( + actor_pubkey, actor_name, "radio.identity", pubkey, "private key imported" + ) + return pubkey + + async def radio_reboot(self, *, actor_pubkey=None, actor_name=None) -> dict: + """Reboot the radio. Drops the bot's link (auto_reconnect restores it). + Audited as 'radio.reboot'.""" + await self.bot.mc.commands.reboot() + await self._audit(actor_pubkey, actor_name, "radio.reboot", None, None) + return {"rebooted": True} + async def send_dm( self, pubkey: str, text: str, *, actor_pubkey=None, actor_name=None, diff --git a/mcbot.py b/mcbot.py index ace24d0..8aa90b9 100755 --- a/mcbot.py +++ b/mcbot.py @@ -1461,6 +1461,17 @@ async def sync_device_info(self) -> None: except Exception: self.logger.exception("get_bat failed") + async def refresh_self_info(self) -> None: + """Re-query the radio's SELF_INFO (appstart refreshes mc.self_info) and + persist it, so the device_info table reflects a just-applied change + immediately rather than on the next periodic sync.""" + try: + ev = await self.mc.commands.send_appstart() + if ev and isinstance(ev.payload, dict): + await self._upsert_device_info(ev.payload, "self_info") + except Exception: + self.logger.exception("refresh_self_info failed") + async def _upsert_device_info(self, payload: dict, group: str) -> None: now = int(time.time()) for k, v in payload.items(): @@ -2680,6 +2691,29 @@ async def _setup_private_key(self) -> bool: self.my_pubkey_byte = self.my_public_key_bytes[0] return True + async def adopt_new_private_key(self, key: bytes) -> str: + """After a new private key is imported to the radio, make the bot adopt + the new identity: refresh the in-memory pubkey fields and rewrite the + cached key file. Without this the OLD cached key is reloaded on the next + start and DM decryption silently breaks. Returns the new pubkey hex.""" + self.my_private_key = bytes(key) + self.my_public_key_bytes = derive_public_key(self.my_private_key) + self.my_pubkey = self.my_public_key_bytes.hex() + self.my_pubkey_byte = self.my_public_key_bytes[0] + path = self.cfg.privkey_path + if path: + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(self.my_private_key) + os.chmod(path, 0o600) + except Exception: + self.logger.exception("failed to rewrite cached key %s", path) + self.logger.warning( + "adopted new radio identity: pubkey=%s (contacts must re-add the " + "bot; old DMs no longer decrypt)", self.my_pubkey, + ) + return self.my_pubkey + async def _seed_channels_from_conf(self) -> None: # one-time seed of the channels table from mcbot.conf's [channels]. # diff --git a/tests/test_radio_config.py b/tests/test_radio_config.py new file mode 100644 index 0000000..d0b6486 --- /dev/null +++ b/tests/test_radio_config.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Radio configuration management: name / radio params / location / loc-policy / +identity import / reboot, with a stubbed radio (bot.mc.commands). + +Run: /home/steve/dev/meshcore/meshcore-bot/venv/bin/python tests/test_radio_config.py +""" + +import asyncio +import os +import sys +from pathlib import Path +from types import SimpleNamespace + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import mcbot # noqa: E402 +from mcbot import derive_public_key # noqa: E402 +from management import MgmtError # noqa: E402 + +_failures = 0 + + +def check(cond, msg): + global _failures + print(f" {'ok' if cond else 'FAIL'}: {msg}") + if not cond: + _failures += 1 + + +def _ok(): + return SimpleNamespace(type=SimpleNamespace(name="OK"), payload={}) + + +def _err(): + return SimpleNamespace(type=SimpleNamespace(name="ERROR"), payload={"reason": "nope"}) + + +class Cmds: + """Records radio commands; returns OK (or ERROR for a named method).""" + def __init__(self, error_on=None): + self.calls = [] + self.error_on = error_on + + def _r(self, name): + return _err() if name == self.error_on else _ok() + + async def set_name(self, name): + self.calls.append(("set_name", name)); return self._r("set_name") + + async def set_radio(self, freq, bw, sf, cr): + self.calls.append(("set_radio", freq, bw, sf, cr)); return self._r("set_radio") + + async def set_tx_power(self, v): + self.calls.append(("set_tx_power", v)); return self._r("set_tx_power") + + async def set_coords(self, lat, lon): + self.calls.append(("set_coords", lat, lon)); return self._r("set_coords") + + async def set_advert_loc_policy(self, pol): + self.calls.append(("set_advert_loc_policy", pol)); return self._r("set_advert_loc_policy") + + async def import_private_key(self, key): + self.calls.append(("import_private_key", bytes(key))); return self._r("import_private_key") + + async def reboot(self): + self.calls.append(("reboot",)); return None + + async def send_appstart(self): + return SimpleNamespace(type=SimpleNamespace(name="SELF_INFO"), payload={"name": "n"}) + + +def make_bot(error_on=None): + cfg = mcbot.Config() + cfg.db_path = Path(":memory:") + cfg.privkey_path = None # skip key-file rewrite in adopt_new_private_key + log = mcbot.logging.getLogger("test-radiocfg") + log.addHandler(mcbot.logging.NullHandler()) + log.propagate = False + bot = mcbot.MCBot(cfg, log) + bot.mc = SimpleNamespace(commands=Cmds(error_on=error_on)) + return bot + + +def called(bot, name): + return [c for c in bot.mc.commands.calls if c[0] == name] + + +async def audit_count(bot, action): + row = await bot.db.fetchone( + "SELECT COUNT(*) AS n FROM bot_audit_log WHERE action=?", (action,) + ) + return row["n"] + + +async def test_set_name(): + print("test_set_name") + bot = make_bot() + r = await bot.mgmt.radio_set_name("NewName") + check(r == {"name": "NewName"}, "returns the name") + check(called(bot, "set_name") == [("set_name", "NewName")], "set_name called") + check(await audit_count(bot, "radio.name") == 1, "audited") + try: + await bot.mgmt.radio_set_name("x" * 33) + check(False, "over-long name should raise") + except MgmtError: + check(True, "over-long name rejected") + bot.db.close() + + +async def test_apply_settings_all(): + print("test_apply_settings_all") + bot = make_bot() + r = await bot.mgmt.radio_apply_settings( + freq=910.525, bw=62.5, sf=7, cr=5, tx_power=22, + lat=30.1, lon=-97.8, adv_loc_policy=True, + ) + check(called(bot, "set_radio") == [("set_radio", 910.525, 62.5, 7, 5)], "set_radio all four") + check(called(bot, "set_tx_power") == [("set_tx_power", 22)], "tx power set") + check(called(bot, "set_coords") == [("set_coords", 30.1, -97.8)], "coords set") + check(called(bot, "set_advert_loc_policy") == [("set_advert_loc_policy", 1)], "loc policy set (1)") + check(await audit_count(bot, "radio.settings") == 1, "audited once") + check(r["rebooted"] is False, "no reboot") + bot.db.close() + + +async def test_apply_settings_guards(): + print("test_apply_settings_guards") + bot = make_bot() + for kw, label in ( + (dict(freq=910.5), "partial radio params"), + (dict(), "no changes"), + (dict(lat=200, lon=0), "lat out of range"), + ): + try: + await bot.mgmt.radio_apply_settings(**kw) + check(False, f"{label} should raise") + except MgmtError: + check(True, f"{label} rejected") + bot.db.close() + + +async def test_apply_settings_reboot(): + print("test_apply_settings_reboot") + bot = make_bot() + r = await bot.mgmt.radio_apply_settings(adv_loc_policy=False, reboot=True) + check(called(bot, "set_advert_loc_policy") == [("set_advert_loc_policy", 0)], "loc policy 0") + check(len(called(bot, "reboot")) == 1 and r["rebooted"] is True, "rebooted") + check(await audit_count(bot, "radio.reboot") == 1, "reboot audited") + bot.db.close() + + +async def test_identity_import(): + print("test_identity_import") + bot = make_bot() + key = os.urandom(64) + expect_pub = derive_public_key(key).hex() + r = await bot.mgmt.radio_apply_identity(private_key=key.hex(), reboot=True) + check(called(bot, "import_private_key") == [("import_private_key", key)], "key imported") + check(r["pubkey"] == expect_pub, "returns new pubkey") + check(bot.my_pubkey == expect_pub, "bot adopted new identity") + check(r["rebooted"] is True and len(called(bot, "reboot")) == 1, "rebooted") + check(await audit_count(bot, "radio.identity") == 1, "identity audited") + # bad key + for bad in ("zz", "ab" * 10): + try: + await bot.mgmt.radio_apply_identity(private_key=bad) + check(False, f"bad key {bad!r} should raise") + except MgmtError: + check(True, f"bad key {bad!r} rejected") + bot.db.close() + + +async def test_radio_error_surfaces(): + print("test_radio_error_surfaces") + bot = make_bot(error_on="set_name") + try: + await bot.mgmt.radio_set_name("x") + check(False, "radio ERROR should raise") + except MgmtError as e: + check("rejected" in e.message, f"ERROR surfaced ({e.message!r})") + bot.db.close() + + +async def main(): + for t in ( + test_set_name, + test_apply_settings_all, + test_apply_settings_guards, + test_apply_settings_reboot, + test_identity_import, + test_radio_error_surfaces, + ): + await t() + print() + if _failures: + print(f"FAILED: {_failures} check(s)") + sys.exit(1) + print("ALL TESTS PASSED") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/web-ui/src/views/Manage.vue b/web-ui/src/views/Manage.vue index 0fa7163..fdff716 100644 --- a/web-ui/src/views/Manage.vue +++ b/web-ui/src/views/Manage.vue @@ -65,8 +65,12 @@ async function loadTab(key, force = false) { const r = await api(endpoints[key]) data.value[key] = decorate(key, r) if (key === 'contacts') rememberContactTypes(data.value[key]) - // Radio tab shows the current periodic flood-advert interval. - if (key === 'radio') await loadAdvertInterval() + // Radio tab: advert interval + presets + snapshot values into the forms. + if (key === 'radio') { + await loadAdvertInterval() + await loadRadioPresets() + populateRadioForms() + } // Commands tab shows the pre-send response delay + no-repeat retry budget. if (key === 'command-config') { await loadCommandDelay() @@ -264,6 +268,109 @@ async function applyAdvertInterval() { await loadAdvertInterval() } +// ---- radio config forms (Identity + Radio settings) ---- +const radioPresets = ref([]) +const maxTxPower = ref(null) +const selectedPreset = ref('') +const identityForm = ref({ name: '', private_key: '' }) +const identityOrig = ref({ name: '', private_key: '' }) +const identityReboot = ref(false) +const radioForm = ref({}) +const radioOrig = ref({}) +const radioReboot = ref(false) +const identityChanged = computed( + () => JSON.stringify(identityForm.value) !== JSON.stringify(identityOrig.value), +) +const radioChanged = computed( + () => JSON.stringify(radioForm.value) !== JSON.stringify(radioOrig.value), +) + +function radioVal(key) { + const rec = deviceInfoMap.value.get(key) + return rec ? parseDevValue(rec.value) : null +} +// snapshot the radio's reported values into the editable forms (form + a +// pristine copy so Save/Cancel only enable on an actual change) +function populateRadioForms() { + identityForm.value = { name: radioVal('self_info.name') ?? '', private_key: '' } + identityOrig.value = { ...identityForm.value } + radioForm.value = { + freq: radioVal('self_info.radio_freq'), + bw: radioVal('self_info.radio_bw'), + sf: radioVal('self_info.radio_sf'), + cr: radioVal('self_info.radio_cr'), + tx_power: radioVal('self_info.tx_power'), + lat: radioVal('self_info.adv_lat'), + lon: radioVal('self_info.adv_lon'), + adv_loc_policy: !!radioVal('self_info.adv_loc_policy'), + } + radioOrig.value = { ...radioForm.value } + maxTxPower.value = radioVal('self_info.max_tx_power') + selectedPreset.value = '' + identityReboot.value = false + radioReboot.value = false +} +async function loadRadioPresets() { + if (radioPresets.value.length) return + try { + radioPresets.value = (await api('/radio/presets')).items + } catch (e) { + error.value = e.message + } +} +function applyPreset() { + const p = radioPresets.value.find((x) => x.name === selectedPreset.value) + if (!p) return // "Custom" clears the selection but keeps the fields + radioForm.value.freq = p.freq + radioForm.value.bw = p.bw + radioForm.value.sf = p.sf + radioForm.value.cr = p.cr +} +function cancelIdentity() { + identityForm.value = { ...identityOrig.value } + identityReboot.value = false +} +async function saveIdentity() { + const f = identityForm.value + const body = { reboot: identityReboot.value } + if (f.name !== identityOrig.value.name) body.name = f.name + if ((f.private_key || '').trim()) { + if ( + !confirm( + 'Import a new private key?\n\nThis gives the node a NEW identity — ' + + 'existing contacts must re-add the bot and old DMs will no longer decrypt.', + ) + ) + return + body.private_key = f.private_key.trim() + } + await run(api('/radio/identity', { method: 'POST', json: body }), 'identity updated', ['radio']) +} +function cancelRadio() { + radioForm.value = { ...radioOrig.value } + selectedPreset.value = '' + radioReboot.value = false +} +async function saveRadio() { + const f = radioForm.value + const o = radioOrig.value + const body = { reboot: radioReboot.value } + // radio params are atomic on the firmware — send all four if any changed + if (['freq', 'bw', 'sf', 'cr'].some((k) => f[k] !== o[k])) { + body.freq = Number(f.freq) + body.bw = Number(f.bw) + body.sf = Number(f.sf) + body.cr = Number(f.cr) + } + if (f.tx_power !== o.tx_power) body.tx_power = Number(f.tx_power) + if (f.lat !== o.lat || f.lon !== o.lon) { + body.lat = Number(f.lat) + body.lon = Number(f.lon) + } + if (f.adv_loc_policy !== o.adv_loc_policy) body.adv_loc_policy = f.adv_loc_policy + await run(api('/radio/settings', { method: 'POST', json: body }), 'radio settings updated', ['radio']) +} + // ---- command response delay (seconds; 0 = disabled) ---- const commandDelay = ref(0) async function loadCommandDelay() { @@ -826,6 +933,71 @@ onUnmounted(() => window.removeEventListener('keydown', onContactKey))
+ +

Identity

+
+
Name
+
+
Public key
+
{{ radioVal('self_info.public_key') || '—' }}
+
New private key
+
+ + +
+
+
+ + + +
+ + +

Radio settings

+
+ + +
+
+
Frequency (MHz)
+
+
Bandwidth (kHz)
+
+
Spreading factor
+
+
Coding rate
+
+
TX power (dBm)
+
+ + max {{ maxTxPower }} +
+
Latitude
+
+
Longitude
+
+
Include location in adverts
+
+
+
+ + + + +
+ + +

Advert

+

Device info

+
what the radio currently reports (read-only)