Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion Bot_Usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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 <lat> <lon> set the node's coordinates
!adm radio advloc <on|off> 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
Expand Down
118 changes: 116 additions & 2 deletions commands/adm.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import json
import time

from management import Management, MgmtError
from management import RADIO_PRESETS, Management, MgmtError

NAME = "adm"
TRIGGERS = ["!adm"]
Expand Down Expand Up @@ -63,6 +63,13 @@
("command delay <seconds>", "delay before sending replies (0 off, 0.1–2.0)"),
("command retry <count>", "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 <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 <lat> <lon>", "set the node's coordinates"),
("radio advloc <on|off>", "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 <flood|zero>", "send a flood or zero-hop advertisement"),
("advert interval <hours>", "send a flood advert every N hours (0 disables)"),
("status", "bot health and runtime stats"),
Expand Down Expand Up @@ -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 <pathhash> ..."
return ("Usage: !adm radio <pathhash|name|set|preset|location|"
"advloc|reboot|key> ...")
op = parts[0].lower()
args = parts[1].strip() if len(parts) > 1 else ""
handler = _RADIO_OPS.get(op)
Expand Down Expand Up @@ -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 <new 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 <lat> <lon>"
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|off>"
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,
}


Expand Down
148 changes: 148 additions & 0 deletions management.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions mcbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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].
#
Expand Down
Loading
Loading