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
15 changes: 15 additions & 0 deletions Bot_Usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -1092,6 +1092,21 @@ allowlist semantics: a command with no listed channels responds on any
channel the bot can decrypt; adding the first channel restricts it to only
the listed channels.

#### Runtime settings

```
!adm setting list all runtime settings and their values
!adm setting <key> show one setting
!adm setting <key> <value> set it (validated, audited, persisted)
```

All DB-persisted tunables (`advert_interval_hours`, `command_delay`,
`channel_retry_max`) live in one registry (`settings.py`); the friendly
aliases above (`!adm command delay/retry`, `!adm advert interval`) and the
web UI controls route through it, so they are interchangeable. Each setting
follows the same lifecycle: the `mcbot.conf` value seeds the database on
first run, after which the DB copy is authoritative and survives restarts.

#### Radio settings

```
Expand Down
145 changes: 45 additions & 100 deletions bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
format_path,
parse_packet_envelope,
)
from settings import SETTINGS

@dataclass
class RepeatWatch:
Expand Down Expand Up @@ -134,20 +135,14 @@ def __init__(self, cfg: Config, log: logging.Logger):
self.evict_headroom: int = cfg.radio_evict_headroom
self._evict_lock = asyncio.Lock()
self._last_auto_evict: float = 0.0 # monotonic
# periodic flood advert. seeded from cfg, then DB-authoritative (loaded
# via _load_advert_interval at startup, persisted by set_advert_interval).
# _last_flood_advert (monotonic) anchors the schedule; the periodic task
# measures the interval from it, and any flood advert (manual or
# periodic) refreshes it.
self.advert_interval_hours: int = cfg.advert_interval_hours
# registry-managed runtime settings (settings.SETTINGS): seeded from
# cfg here, then DB-authoritative once load_runtime_settings() runs.
for _s in SETTINGS.values():
setattr(self, _s.key, getattr(cfg, _s.key))
# _last_flood_advert (monotonic) anchors the periodic-advert schedule;
# the periodic task measures advert_interval_hours from it, and any
# flood advert (manual or periodic) refreshes it.
self._last_flood_advert: float = 0.0
# delay before transmitting command responses (seconds). seeded from
# cfg, then DB-authoritative (loaded via _load_command_delay at startup,
# persisted by set_command_delay).
self.command_delay: float = cfg.command_delay
# no-repeat channel resend budget. seeded from cfg, then DB-authoritative
# (loaded via _load_channel_retry_max, persisted by set_channel_retry_max).
self.channel_retry_max: int = cfg.channel_retry_max

# channel logging filter
def _parse_log_channels(self) -> None:
Expand Down Expand Up @@ -2467,98 +2462,50 @@ async def send_advert(self, flood: bool):
self._last_flood_advert = time.monotonic()
return ev

# --- periodic flood advert (interval seeded from config, then DB-managed) ---
async def _load_advert_interval(self) -> None:
# DB is authoritative; seed it from config on first run (key absent).
row = await self.db.fetchone(
"SELECT value FROM bot_meta WHERE key='advert_interval_hours'"
)
val = row["value"] if row else None
if val is not None and str(val).lstrip("-").isdigit():
self.advert_interval_hours = max(0, int(val))
else:
self.advert_interval_hours = self.cfg.advert_interval_hours
await self.db.execute(
"INSERT INTO bot_meta(key,value) VALUES('advert_interval_hours',?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(str(self.advert_interval_hours),),
)

async def set_advert_interval(self, hours: int) -> None:
# update the runtime value, persist to the DB, and restart the schedule
# from now (so a change doesn't trigger an immediate overdue advert).
self.advert_interval_hours = max(0, int(hours))
# --- runtime settings (registry-driven; each seeded from config on
# first run, then DB-managed — see settings.SETTINGS) ---
def reset_advert_schedule(self) -> None:
# restart the periodic-advert schedule from now, so an interval
# change doesn't trigger an immediate overdue advert.
self._last_flood_advert = time.monotonic()
await self.db.execute(
"INSERT INTO bot_meta(key,value) VALUES('advert_interval_hours',?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(str(self.advert_interval_hours),),
)
self.logger.info(
"flood advert interval set to %dh%s",
self.advert_interval_hours,
" (disabled)" if self.advert_interval_hours == 0 else "",
)

# --- command response delay (seeded from config, then DB-managed) ---
async def _load_command_delay(self) -> None:
# DB is authoritative; seed it from config on first run (key absent).
row = await self.db.fetchone(
"SELECT value FROM bot_meta WHERE key='command_delay'"
)
val = row["value"] if row else None
try:
self.command_delay = min(2.0, max(0.0, float(val)))
except (TypeError, ValueError):
self.command_delay = self.cfg.command_delay
await self.db.execute(
"INSERT INTO bot_meta(key,value) VALUES('command_delay',?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(str(self.command_delay),),
)

async def set_command_delay(self, seconds: float) -> None:
self.command_delay = min(2.0, max(0.0, float(seconds)))
await self.db.execute(
"INSERT INTO bot_meta(key,value) VALUES('command_delay',?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(str(self.command_delay),),
)
self.logger.info(
"command response delay set to %.1fs%s",
self.command_delay,
" (disabled)" if self.command_delay == 0 else "",
)

# --- channel no-repeat retry budget (seeded from config, then DB-managed) ---
async def _load_channel_retry_max(self) -> None:
# DB is authoritative; seed it from config on first run (key absent).
row = await self.db.fetchone(
"SELECT value FROM bot_meta WHERE key='channel_retry_max'"
)
val = row["value"] if row else None
try:
self.channel_retry_max = min(5, max(0, int(val)))
except (TypeError, ValueError):
self.channel_retry_max = self.cfg.channel_retry_max
await self.db.execute(
"INSERT INTO bot_meta(key,value) VALUES('channel_retry_max',?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(str(self.channel_retry_max),),
async def load_runtime_settings(self) -> None:
# DB is authoritative; seed it from config on first run (key absent
# or unparseable).
for s in SETTINGS.values():
row = await self.db.fetchone(
"SELECT value FROM bot_meta WHERE key=?", (s.key,)
)

async def set_channel_retry_max(self, count: int) -> None:
self.channel_retry_max = min(5, max(0, int(count)))
try:
value = s.clamp(row["value"])
except (TypeError, ValueError):
value = getattr(self.cfg, s.key)
await self.db.execute(
"INSERT INTO bot_meta(key,value) VALUES(?,?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(s.key, str(value)),
)
setattr(self, s.key, value)

async def set_runtime_setting(self, key: str, value):
# apply + persist a registry setting and run its side-effect hook.
# validation is the caller's job (management.setting_set).
s = SETTINGS[key]
value = s.clamp(value)
setattr(self, s.key, value)
await self.db.execute(
"INSERT INTO bot_meta(key,value) VALUES('channel_retry_max',?) "
"INSERT INTO bot_meta(key,value) VALUES(?,?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(str(self.channel_retry_max),),
(s.key, str(value)),
)
if s.on_set:
getattr(self, s.on_set)()
self.logger.info(
"channel no-repeat retry budget set to %d%s",
self.channel_retry_max,
" (disabled)" if self.channel_retry_max == 0 else "",
"%s set to %s%s%s",
s.label, value, f" {s.unit}" if s.unit else "",
" (disabled)" if not value else "",
)
return value

async def send_channel_text(self, channel_idx: int, text: str):
# single-shot channel send (channel messages have no ACK). returns
Expand Down Expand Up @@ -2738,9 +2685,7 @@ async def run(self) -> int:
await self._program_channels_on_radio()
await self._bootstrap_admin_state()
await self.seed_command_configs()
await self._load_advert_interval()
await self._load_command_delay()
await self._load_channel_retry_max()
await self.load_runtime_settings()

# ensure radio contact-table headroom on startup (device_info +
# contacts have been synced above; owners are bootstrapped into
Expand Down
42 changes: 36 additions & 6 deletions commands/adm.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
("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)"),
("setting [key] [value]", "list/show/set any runtime setting"),
("status", "bot health and runtime stats"),
("reload", "rescan commands/ and reload plugins"),
("restart", "full teardown + reinit (re-reads config)"),
Expand Down Expand Up @@ -677,10 +678,12 @@ async def _command_delay(ctx, rest):
except ValueError:
return f"Invalid delay: {arg!r}. Use seconds (0, or 0.1–2.0)."
try:
res = await ctx.bot.mgmt.set_command_delay(seconds, **_actor(ctx))
res = await ctx.bot.mgmt.setting_set(
"command_delay", seconds, **_actor(ctx)
)
except MgmtError as e:
return e.message
d = res["delay"]
d = res["value"]
if d == 0:
return "Command response delay disabled"
return f"Command response delay set to {d:.1f}s"
Expand All @@ -700,10 +703,12 @@ async def _command_retry(ctx, rest):
except ValueError:
return f"Invalid retry count: {arg!r}. Use a whole number (0–5)."
try:
res = await ctx.bot.mgmt.set_channel_retry(count, **_actor(ctx))
res = await ctx.bot.mgmt.setting_set(
"channel_retry_max", count, **_actor(ctx)
)
except MgmtError as e:
return e.message
n = res["retries"]
n = res["value"]
if n == 0:
return "Channel no-repeat retry disabled"
return f"Channel no-repeat retries set to {n}"
Expand Down Expand Up @@ -904,10 +909,12 @@ async def _cmd_advert(ctx, rest):
except ValueError:
return f"Invalid interval: {parts[1]!r}. Use a whole number of hours."
try:
res = await ctx.bot.mgmt.radio_set_advert_interval(hours, **_actor(ctx))
res = await ctx.bot.mgmt.setting_set(
"advert_interval_hours", hours, **_actor(ctx)
)
except MgmtError as e:
return e.message
n = res["interval_hours"]
n = res["value"]
if n == 0:
return "Periodic flood advert disabled"
return f"Flood advert every {n}h"
Expand All @@ -921,6 +928,28 @@ async def _cmd_advert(ctx, rest):
return f"Sent {'flood' if flood else 'zero-hop'} advert"


async def _cmd_setting(ctx, rest):
# generic accessor for registry settings; friendly aliases like
# '!adm command delay' remain and route through the same registry.
parts = rest.split(maxsplit=1)
if not parts:
rows = await ctx.bot.mgmt.setting_list(**_actor(ctx))
return "\n".join(
f"{s['key']} = {s['value']}{' ' + s['unit'] if s['unit'] else ''}"
f" (0–{s['max']:g})"
for s in rows
)
key = parts[0].lower()
try:
if len(parts) == 1:
res = await ctx.bot.mgmt.setting_get(key, **_actor(ctx))
return f"{res['key']} = {res['value']}"
res = await ctx.bot.mgmt.setting_set(key, parts[1].strip(), **_actor(ctx))
except MgmtError as e:
return e.message
return f"{res['key']} set to {res['value']}"


async def _cmd_status(ctx, _):
bot = ctx.bot
name_row = await bot.db.fetchone(
Expand Down Expand Up @@ -1047,6 +1076,7 @@ async def _cmd_log(ctx, rest):
"command": _cmd_command,
"radio": _cmd_radio,
"advert": _cmd_advert,
"setting": _cmd_setting,
"status": _cmd_status,
"reload": _cmd_reload,
"restart": _cmd_restart,
Expand Down
Loading
Loading