Commit Graph
1033 Commits
Author SHA1 Message Date
agessaman 3ff24a93f6 fix(services): back off a service that does not start instead of retiring it
Review found that marking a not-running service inactive for good also retired services that only returned early for a transient reason: packet capture and the map uploader give up after waiting 30 s for the radio, and the bridges return when meshcore is momentarily unset, which is exactly when the health loop restarts them. Such a service now records a restart failure, so the next attempt waits out service_restart_backoff_seconds; a transient failure recovers, a missing configuration costs one attempt per backoff instead of one every 5 s. The restart decision is _service_restart_due, which the tests now exercise directly.

Also from the review: packet capture and the map uploader never cleared the should_exit flag stop() sets, so after a restart their background loops exited at once while the service reported healthy; start() now clears it. The Discord and Telegram bridges close the HTTP session they open when they return early because meshcore is not available.
2026-10-01 11:47:47 -07:00
agessaman a3b0f56639 fix(services): stop restarting unconfigured services, and unsubscribe on stop
The health loop restarted every enabled service whose is_healthy() was False. A service that returns from start() without running because it has nothing to do (Discord or Telegram with no channels, the map uploader with no key) is never healthy, and its restart raised nothing, so it counted as a success and cleared the backoff: the service was restarted every 5 seconds, logging a warning each time.

Services whose start() returns without running are now recorded as inactive, logged once, and skipped by the health loop. A restart that leaves a service running but unhealthy records a failure, so the next attempt waits out service_restart_backoff_seconds.

Restarts also double-subscribed: services subscribed to meshcore events in start() and never unsubscribed (the comments said meshcore cleans up; it does not). BaseServicePlugin gains _subscribe and _unsubscribe_all; the Discord, Telegram, map uploader, packet capture and prefix-collision services subscribe through it, unsubscribe in stop(), and drop the old instance's subscription before re-subscribing after a transport reconnect. Tests use meshcore's real EventDispatcher and fail on dev.
2026-10-01 11:25:18 -07:00
Adam Gessaman 401125277a Merge pull request #315 from agessaman/fix/repeater-geocode-off-loop
fix(repeater_manager): reverse-geocode in a worker thread, not on the event loop
2026-10-01 09:22:00 -07:00
agessaman e79e7ada77 Merge remote-tracking branch 'origin/dev' into fix/repeater-geocode-off-loop 2026-10-01 09:15:24 -07:00
Adam Gessaman 28827d8624 Merge pull request #316 from agessaman/test/stats-perf-timing
test(dashboard): judge /api/stats speed by the fastest of five requests
2026-10-01 09:15:21 -07:00
agessaman e02b198eb1 test(dashboard): judge /api/stats speed by the fastest of five requests
The threshold test timed one request, so a single GC pause or a busy CI runner could fail it. On the Python 3.10 runner it failed four times in a row at 0.22 to 0.26 s on a branch that does not touch the stats path, while the same request takes about 22 ms locally on both that branch and dev, with or without coverage. It now times five requests and asserts on the fastest, which an algorithmic regression still slows, and the failure message lists every timing.
2026-10-01 09:03:17 -07:00
agessaman b6538de760 Merge remote-tracking branch 'origin/dev' into fix/repeater-geocode-off-loop
# Conflicts:
#	CHANGELOG.md
2026-10-01 08:31:15 -07:00
Adam Gessaman 0173b4a52b Merge pull request #310 from agessaman/fix/dm-ack-timeout-floor
fix(dm): wait at least dm_min_ack_timeout for each DM attempt's ACK
2026-10-01 08:27:26 -07:00
agessaman eb1f14479e Merge remote-tracking branch 'origin/dev' into fix/dm-ack-timeout-floor
# Conflicts:
#	CHANGELOG.md
2026-10-01 08:15:55 -07:00
Adam Gessaman 585812095d Merge pull request #312 from agessaman/fix/flood-scope-error-detection
fix(send): detect a radio rejecting set_flood_scope
2026-10-01 08:12:16 -07:00
agessaman 6ab83a1bb4 fix(repeater_manager): serialize adverts per contact so a burst geocodes once
Moving the geocode decision ahead of the await meant every advert that arrived while another was still geocoding read the same uncommitted row and geocoded again; six adverts for one contact made nine or more Nominatim requests instead of two, at the 1 req/s budget. track_contact_advertisement now holds a per-contact asyncio lock across the read, geocode and write, which restores the old one-at-a-time behavior for a contact while different contacts still proceed independently. The lock entry is dropped when its last holder leaves.

Also: geocoding_cache reads in the worker-thread helpers use .get() so the loop-thread expiry pass cannot raise KeyError between the check and the read, the write-block duplicate guard calls _is_duplicate_advert_packet, and the guard test loses leftover scaffolding.
2026-10-01 07:34:43 -07:00
agessaman 74c25fc5be fix(repeater_manager): reverse-geocode in a worker thread, not on the event loop
track_contact_advertisement, _background_geocoding, scan_and_catalog_repeaters and populate_missing_geolocation_data are async but called rate-limited Nominatim synchronously (with the rate limiter's time.sleep), so the whole bot stalled for each lookup. The async-path guard test only scanned commands and service plugins, so it never saw this file.

The calls now go through asyncio.to_thread. track_contact_advertisement decides whether to geocode, and does it, before its write block, which stays free of awaits so concurrent adverts cannot interleave between its reads and writes; the duplicate-packet guard runs both before geocoding and again inside the write block. The guard test now scans repeater_manager.py and knows the state/country and full-location helpers; a new test shows advert geocoding runs off the loop thread and fails without the change.
2026-10-01 06:47:22 -07:00
agessaman b7e1b5b3da fix(send): detect a radio rejecting set_flood_scope
send_channel_message compared the set_flood_scope reply's type to the string "ERROR", but it is an EventType whose value is "command_error", so the comparison never matched. A rejected regional scope sent the message at whatever scope the radio held, and a rejected restore to global flood left the radio pinned to the region for every later send, both without a log line. Compare against EventType.ERROR.

The existing tests for this path built their error reply with the same "ERROR" string, so they passed against the broken check; they now use EventType.ERROR and fail without the fix.
2026-10-01 00:39:55 -07:00
agessaman 2cc50c5878 fix(dm): cap dm_min_ack_timeout and correct how the firmware estimate is described
Review follow-up. The firmware's direct-path estimate includes hop count and about 750 ms of fixed allowance, not airtime alone, so the comments and changelog now say that. Values above 60 seconds are capped, since a value meant as milliseconds would otherwise hold one DM for hours, and the changelog notes that an unacknowledged DM now takes at least 24 seconds to report failure. The late-ACK test has a wider margin so it cannot flake on a loaded machine.
2026-10-01 00:27:21 -07:00
agessaman 5452fd58ee fix(dm): wait at least dm_min_ack_timeout for each DM attempt's ACK
send_msg_with_retry was called with timeout=0, so each attempt waited 1.2 times the radio's suggested_timeout. The companion firmware derives that from airtime alone (500 + (6 x airtime + 250) x (hops + 1) ms direct, 500 + 16 x airtime flood), but the round trip also carries fixed costs that do not shrink with airtime: the recipient's ACK delay, repeater retransmit and rx delays, the radio's TX queue and the host link. On a fast preset a zero-hop attempt gave up after about 1.3 seconds, the ACK of a delivered message arrived after the last attempt, and the send reported failure. That is the residual #290 symptom: webhook DMs erroring within a couple of seconds for messages the recipient had.

New [Bot] dm_min_ack_timeout (default 8 seconds) is passed as min_timeout. An ACK still ends the wait at once, so successful sends are no slower, and since the radio lock is per-frame the longer wait on a failing DM does not hold up other sends. 0 restores the old behavior. The new test drives the real meshcore retry loop with an ACK slower than the radio's estimate and fails without the floor.
2026-09-30 22:20:10 -07:00
Adam Gessaman e8eadd9078 Merge pull request #304 from agessaman/fix/channel-frame-budget
fix(send): size channel text to fill the 160-byte cipher block
2026-09-23 21:49:16 -07:00
agessaman 807dd3da24 Merge remote-tracking branch 'origin/dev' into fix/channel-frame-budget
# Conflicts:
#	CHANGELOG.md
2026-09-23 21:44:08 -07:00
agessaman 02ad556582 fix(send): size channel text to fill the 160-byte cipher block
Builds on #303. Channel text is capped at 155 bytes of "<name>: <text>" instead of 124. The firmware encrypts a 4-byte timestamp and a 1-byte text type ahead of the text in 16-byte AES blocks, so 155 fills the 160-byte block exactly (163-byte payload), and the last 5 bytes up to the firmware's 160 would spill into another block (179-byte payload).

124 was set on the premise that repeaters drop 165-byte frames. They don't: allowPacketForward has no size check and MAX_PACKET_PAYLOAD is 184, and 163-byte GRP_TXT payloads are routinely seen at 3+ hops. The frame-budget tests now pin the limit within the firmware's 160 and to a whole cipher block instead of to a 131-byte relay ceiling.

Also corrects the payload model's plaintext header from 4 bytes to 5, and moves a test import back above the helper that had split the import block.
2026-09-23 21:38:19 -07:00
Adam GessamanandGerard Hickey dd546a12ed feat(web_viewer): split anonymous read access from an admin session (#300)
* feat(web_viewer): split anonymous read access from an admin session

When web_viewer_password is set, Dashboard, Contacts, and Mesh Graph are readable without logging in, along with the read-only APIs they need. Radio, Config, Logs, every mutating API, /api/channels, and the live Socket.IO streams require an admin session, since those can expose channel keys and decrypted traffic. The Realtime page still renders publicly but shows "Admin login required" instead of connecting. Anything not on the public allowlist fails closed.

Login now compares HMAC digests with hmac.compare_digest and clears the session before setting authenticated_admin. Logout clears the whole session and returns to the dashboard.

Co-authored-by: Gerard Hickey <hickey@kinetic-compute.com>

* fix(web_viewer): stop Contacts redeclaring IS_ADMIN and hide banner admin buttons

contacts.html declared its own top-level const IS_ADMIN, but base.html already declares one in an earlier script on the same page. Classic scripts share one global lexical scope, so the redeclaration threw a SyntaxError and the whole Contacts script never ran, leaving the table on its loading placeholder in every mode. Contacts now uses the global from base.html.

The zombie-recover and clear-offline banner buttons were still rendered for anonymous viewers even though their /api/admin/* endpoints now return 401. They are gated on the same auth_enabled/is_admin check as the other admin controls.

* fix(web_viewer): drop sockets on logout, block off-site login redirects, allow HEAD on public paths

Logging out cleared the HTTP session but left that login's Socket.IO connections open, and a socket keeps the session it connected with, so a /realtime tab kept streaming packets, decrypted messages, and logs after logout. Each login now gets an admin_login_id, sockets record it on connect, and logout disconnects the sockets opened under that login. Other admin logins stay connected.

The login next check passed ///host and /\host because urlparse sees no netloc in either, but browsers resolve both as another origin. Next targets are now rejected if they start with //, contain a backslash, or contain a tab or newline. This check predates the admin split.

Public pages and GET APIs only accepted literal GET, so HEAD (and OPTIONS) on them returned 401 and made uptime checks report the viewer as down. They now accept HEAD and OPTIONS too; admin paths are unchanged.

* docs(changelog): note the login redirect fix

* fix(web_viewer): pass IS_ADMIN through a body data attribute so ESLint can parse base.html

The frontend lint job failed with "Parsing error: Unexpected token {" because base.html's inline script assigned IS_ADMIN from a raw Jinja expression, which isn't valid JS until rendered. The flag now lives in data-is-admin on <body> and the script reads it from document.body.dataset. I also dropped the unused AUTH_ENABLED constant and declared IS_ADMIN as a readonly ESLint global, since contacts.html reads it from base.html.

---------

Co-authored-by: Gerard Hickey <hickey@kinetic-compute.com>
2026-09-23 20:47:58 -07:00
CoderNemesis 1b2831c47f Fix message byte limit when splitting messages and documentation from PR#299 2026-09-23 14:46:35 -05:00
agessaman c5cdd63d7e fix(web_viewer): store packet hash on captured channel messages
Decoded packet_stream rows had no way back to the original packet, so alternate routes could not be joined. The hash is already on routing_info; keep it in the existing JSON blob.
2026-09-22 16:56:12 -07:00
agessaman 1e7951b3a5 docs: state that a blank flood_scopes list does not mirror region
A blank list still answers channel commands as ordinary global FLOOD. The web viewer text implied the reply would follow the sender's scope.
2026-09-22 16:56:08 -07:00
agessaman 9d39f953e7 docs(config): update flood_scopes documentation for clarity on mirroring behavior 2026-09-22 16:52:18 -07:00
CoderNemesis 5175558dd7 fix(send): split oversized channel text before it stalls the radio (#299)
An oversized webhook or channel body was handed to the firmware whole. The device dropped it without an ack, retries stalled the transport, and the radio looked dead. Split to the frame budget, keep links intact, and tag multi-part sends so they can be reassembled.

Reserve the wx location prefix from that same budget, and let NOAA alert polling be turned off without disabling the rest of the weather service.
2026-09-22 16:21:54 -07:00
Adam Gessaman 4f323bad6d Merge pull request #297 from 8Thomas8/fix/footer-meshcore-link
fix(web_viewer): point the footer MeshCore link at the official site
2026-09-21 17:03:46 -07:00
Thomas Rousseau e85176dbdf fix(web_viewer): point the footer MeshCore link at the official site 2026-09-22 01:45:15 +02:00
agessaman 53dcc2d0e7 docs(changelog): drop the "!" command prefix and space out no em dashes in 1.1.0
Commands are bare by default; `command_prefix` is empty unless configured, so
`!wx` misrepresents a stock install. Shipped release sections are left alone.
2026-09-20 12:50:38 -07:00
agessaman 31e56b3766 chore(release): prepare 1.1.0
Fold the Unreleased section into 1.1.0 and date it. The 1.1.0 heading was
written on 2026-09-13 but never tagged, so everything merged since belongs in
it; the section also carried two separate "### Added" headings from earlier
merges, now consolidated. All 79 entries preserved.

Extend the v1.0.0 -> v1.1.0 upgrade guide with the meshcore 2.3.14 requirement,
migration 24, and the configuration and behavior changes that landed after the
heading was first written.
2026-09-20 11:44:34 -07:00
agessaman cc6ca00b4e docs(keywords): say the newline escape is a backslash, not a slash (#277)
The report used /n and got two literal characters. The escape works the same in
every keyword path — I checked the test command's template engine too — so the
defect is that the instructions never said "backslash" and used `test` as their
example, which is the one keyword a built-in command owns. That command renders
through a different engine and silently drops the mesh-info placeholders listed
in the same block.

Also documents the configparser continuation form, which needs no escape, and
corrects two packet-capture examples that still showed a naive local timestamp
where the bot has published UTC with a Z suffix since v1.0.0 (#276).
2026-09-20 10:58:17 -07:00
agessaman 4312d8a39c fix(packet_capture): default waev.app brokers to a JWT TTL they accept (#248)
waev.app rejects a token whose exp is more than an hour past its iat, so the
project-wide 24-hour default could never authenticate there — the operator saw
only a bare auth failure with no hint that the lifetime was the problem, while
the same public key worked from meshcoretomqtt.

waev.app hosts now resolve to a 3600s TTL with renewal at 3500s when no value
was configured for them, per broker or globally; a configured value still wins,
and the substitution is logged. Matching is the apex and its subdomains only, so
a lookalike host like waev.app.example.com does not qualify.
2026-09-20 10:58:09 -07:00
agessaman c573755324 feat(region): region-code monitoring, warnings, and Radio-page scope settings (#279, #283)
Classify every channel message's flood scope, tally it per channel per day,
and optionally warn senders whose messages carry no region code. Warnings are
off by default and start in dry run; they fire only on positive RF evidence of
an unscoped FLOOD, and are fenced by a per-sender cooldown, a mesh-wide
cooldown and a daily cap read from the database.

Adds Region Scopes and Default Region Scope cards to the Radio page so the
bot's own scopes and the radio's firmware default can be set from the web
viewer.

# Conflicts:
#	CHANGELOG.md
2026-09-20 10:47:24 -07:00
agessaman b5e61306b2 Merge remote-tracking branch 'origin/dev' into feat/region-code-warnings
# Conflicts:
#	CHANGELOG.md
2026-09-20 10:43:24 -07:00
agessaman 5751f36335 docs(changelog): move the command-development guide entry to Unreleased
It landed under the shipped 1.1.0 heading in #259.
2026-09-20 10:42:44 -07:00
Gerard Hickey 0fe22d2b4f docs: Documentation for developing local commands (#259)
* docs: Documentation for developing local commands

Signed-off-by: Gerard Hickey <hickey@kinetic-compute.com>

* Updated for developing using the local directory

Signed-off-by: Gerard Hickey <hickey@kinetic-compute.com>

* Minor updates and corrections

Signed-off-by: Gerard Hickey <hickey@kinetic-compute.com>

* Implementing recommendations from agessaman

Signed-off-by: Gerard Hickey <hickey@kinetic-compute.com>

* Update CHANGELOG.md

Signed-off-by: Gerard Hickey <hickey@kinetic-compute.com>

---------

Signed-off-by: Gerard Hickey <hickey@kinetic-compute.com>
2026-09-20 10:42:04 -07:00
Gerard Hickeyandagessaman af05c2ef7f feat(website): Include local commands in generated HTML (#287)
* feat(website): Include local commands when generating website

Signed-off-by: Gerard Hickey <hickey@kinetic-compute.com>

* feat(website): Disabled commands are excluded from generated HTML

Signed-off-by: Gerard Hickey <hickey@kinetic-compute.com>

* Updated CHANGELOG.md

Signed-off-by: Gerard Hickey <hickey@kinetic-compute.com>

* fix(website): resolve enablement through the bot's own config helpers

The generated page is meant to list what the bot actually answers, so it
has to read the config the same way the bot does. Three places where it
did not:

The local `config.ini` overlay was never read. `read_config` parsed only
the base file, while the bot overlays `<local_dir_path>/config.ini` on
top of it, and that overlay is exactly where the web viewer saves a local
plugin's settings. A local command disabled from the settings page still
showed up on the site.

`is_command_enabled` derived the section name and the legacy `enabled`
spellings by hand, duplicating `BaseCommand._derive_config_section_name`
and guessing that a legacy key lives in the command's own section. Half
of them do not: `[Jokes] joke_enabled` disables `Joke_Command`. It now
calls `command_section_name` and `read_enabled`, which share the alias
table with the runtime and the settings UI.

That table was missing the same-section `[Joke_Command] joke_enabled` and
`[DadJoke_Command] dadjoke_enabled` spellings, which both commands accept
through their own fallback. The settings page read only the `[Jokes]`
form, so a bot disabled the same-section way displayed as enabled.

Also fold the duplicated local-commands-dir resolution into
`resolve_local_commands_dir`, take `bot_root` as a `MinimalBot` argument
instead of assigning it post-construction twice, and restore the `hidden`
attribute check that `generate_samples` lost when it moved to
`filter_commands`.

---------

Signed-off-by: Gerard Hickey <hickey@kinetic-compute.com>
Co-authored-by: agessaman <adam@gessaman.com>
2026-09-19 22:33:54 -07:00
Piotr Synowiec 258c9680b2 feat(hello): optionally address the sender in the reply (#292)
Adds a `[Hello_Command] include_sender` setting (default off) that names the user the hello reply is answering, so a busy channel can tell whose greeting the bot picked up.

The mention takes the place of the random human descriptor, keeping the translated sentence intact, and is dropped when it would push the reply past the channel body budget. DMs are unaffected, and channel sender names are sanitized before being echoed.
2026-09-19 21:28:19 -07:00
Gerard Hickeyandagessaman f9b1cbddb8 feat(command): Add contact command (#293)
* feat(command): Add contact command

The contact command allows the user to ask the bot to provide its
public key as a clickable contact. This allows the user to then DM the
bot outside of a channel.

Signed-off-by: Gerard Hickey <hickey@kinetic-compute.com>

* fix(contact): harden matching and self_info handling, add tests and docs

Review follow-ups on the contact command:

- Match through _cleaned_content_matches so a non-matching message keeps its
  original content. The raw cleanup_message_for_matching call rewrote
  message.content in place during the keyword scan, which is the #267
  regression every other command already avoids.
- Match against self.keywords so a configured alias works.
- Read self_info defensively (dict or object, may be absent) and validate the
  public key instead of sending a literal "<None:1:None>" over the air.
- Drop the hardcoded '!' prefix strip in execute; matching already normalizes
  the content, and prefixes are configurable.
- Forward skip_channel_check to the base can_execute.
- Remove copy-paste docstring leftovers from the roll command.
- Move [Contact_Command] into the alphabetical run in config.ini.example and
  fix the "chanels" typo.
- Add tests, a docs/command-reference.md entry, and flesh out the changelog.

---------

Signed-off-by: Gerard Hickey <hickey@kinetic-compute.com>
Co-authored-by: agessaman <adam@gessaman.com>
2026-09-19 20:19:38 -07:00
Adam Gessaman 38b259156e chore(deps): require meshcore 2.3.14 for the send_msg_with_retry ACK fix (#294)
The ACK fix I wrote for the radio-lock work is now upstream and released
(meshcore_py#108, v2.3.13). Before it, send_msg_with_retry reported ACKed DMs as
failures: the ACK subscription was registered only after send_msg returned, so
an ACK queued right behind MSG_SENT was dispatched with no listener, and each
attempt accepted only its own ACK code, so a late ACK answering an earlier
attempt was ignored. The bot logged "no ACK received after retries" and skipped
the delivery bookkeeping for messages the recipient had in fact received. Pin
2.3.14, which also fixes send_cmd's destination-type handling.

Two fixes from re-reading the merged radio-lock change:

A region-scoped channel message now restores global flood even when
set_flood_scope raises. The restore only ran in the finally around the send, so
a set that raised left the device pinned to that region and every later send
went out under it.

Drop the __setattr__ left behind when _SerializedCommands became
_serialize_command_frames; it sat after the function's return, unreachable, and
still referenced the proxy's _commands slot.
2026-09-19 17:28:14 -07:00
Adam Gessaman 9939e22575 fix(core): release the radio lock while waiting for ACKs (#291)
Radio commands were serialized per method call, so a composite command held the lock for everything it awaited. A DM's send_msg_with_retry kept every other command waiting through all of its ACK timeouts (up to ~36 s with three attempts), stalling channel replies, other DMs, scheduled sends, and automatic message fetching. req_regions_sync did the same for each neighbor scope reply.

Serialize at the frame instead: wrap CommandHandler.send() on the handler instance, which is where every meshcore command writes its frame and waits for the radio's immediate reply. There is still at most one in-flight companion frame, paced as before, and library methods that call self.send are covered without a proxy. Waits for ACKs and remote responses now run outside the lock.

Add MeshCoreBot.radio_session() for short sequences that must not interleave, and use it for region-scoped channel sends so set scope, send, and restore stay together. The per-call lock never guaranteed that: a DM retry loop queued between set_flood_scope and send_chan_msg sent its flood attempts under the channel's scope.
2026-09-19 09:12:07 -07:00
agessaman 1a8f108ac0 fix(types): resolve mypy errors in channel secret lookup
_channel_secrets() assigned dict_items and enumerate to the same
variable, and mypy inferred Any | None for the channel_idx .get() with
a mixed Any | int default. Declare items as Iterable[tuple[int, Any]]
and hold the raw index as Any. Runtime behavior is unchanged.

Also cover the list layout meshcore actually uses, where entries
without channel_idx fall back to their position.
2026-09-17 22:01:50 -07:00
Gerard Hickey 6686564a25 feat(website): add --link-css and --embed-css for custom CSS (#288)
generate_website.py can layer custom CSS on top of the built-in style chosen with --style. --embed-css appends a local file to the page's <style> block so the page stays a single file, and --link-css adds a stylesheet link after it, so both override built-in rules of equal specificity. The flags can be combined (the linked stylesheet loads last), apply to --sample pages, and an unreadable --embed-css file stops generation with an error. The website docs gain a custom CSS guide and a reference for the generated page's structure, classes, and theme variables.
2026-09-17 21:47:31 -07:00
agessaman c566fa97c8 fix(channel): improve channel fetching with retries and handle empty results cleanly (#266) 2026-09-16 17:32:54 -07:00
agessaman 16cce92c46 feat(region): set region scopes from the Radio page (#283)
Region scopes had to be hand-edited in config.ini, and the radio's own default
region could not be set from the bot at all. Both now live on the Radio page.

Node Settings gains a Default Region Scope field beside the path hash size,
which is the firmware's own setting — NodePrefs.default_scope_name and
default_scope_key, the region the radio falls back to for any send the bot does
not scope itself. Firmware that has no such command is reported as not having
answered rather than shown as an empty field, and a stored key that is not the
stored name's hash is flagged, because the radio routes by the key and the name
beside it is only a label. A build-flag default stores the bare name while
hashing the '#' form, so the comparison normalizes first.

Clearing it does not go through the meshcore library, because none of its reset
paths can: set_default_flood_scope(None) raises on len(None), "" earns
ILLEGAL_ARG from the firmware, and "*" only works because the frame is padded by
character count rather than by the name it wrote, landing one byte short of the
length the firmware reads as "a scope follows". The firmware's own contract for
clearing is a bare CMD_SET_DEFAULT_FLOOD_SCOPE, so that is what goes out. The
same padding bug is why a device scope name must be ASCII: a multi-byte
character displaces the transport key out of its field.

A separate Region Scopes card covers the bot's side — [Channels] flood_scopes as
an explicit choice between "reply whatever the scope" and an allowlist, and
outgoing_flood_scope_override. It writes config.ini, queues a hot reload, then
polls that reload and reports what the bot did with it, including saying plainly
when nothing picked it up. Per-channel flood_scope.<channel> entries are listed
read-only, since they are the reason a channel can ignore the default.

The two settings interact in a way worth stating on the page: once the bot sends
a scoped message it restores with set_flood_scope("*"), which leaves the radio in
forced-unscoped mode, so the device default stops applying until the bot scopes
another send.

Also from building it:

- flood_scopes left in [Bot] is surfaced rather than hidden. CommandManager
  still honours it when [Channels] is empty, so a page that ignored it would
  show "replies to every scope" while the bot enforced an allowlist — and
  saving "off" hands the allowlist straight back to [Bot], which the banner now
  says instead of claiming the old key stops mattering.
- outgoing_flood_scope_override = none is read as global flood on the send path,
  as it already was everywhere else. send_channel_message tested the raw value
  against a fixed tuple, so the lowercase spelling became the region "#none".
- Scope normalization moved to modules/flood_scope.py so the viewer, a separate
  process, can validate a typed name without importing the bot's command
  machinery. CommandManager delegates to it.
- Dark mode named only .border, so a .border-start divider drew Bootstrap's
  light #dee2e6 onto a dark card. All four directional utilities now match.
2026-09-16 07:59:10 -07:00
agessaman 10b6b6b01d fix(region): report warnings withheld for want of a contact
The known-contact gate for DM delivery returns before anything is recorded, so
a bot that keeps no contacts dropped every warning and wrote no rows: the page
showed "No warnings decided yet" indefinitely beside a status card saying it
was sending, and nothing distinguished a clean mesh from one where every
warning was being discarded. Only a debug log said otherwise.

Withheld warnings are now counted per local day in bot_metadata — a counter
rather than event rows, because this fires ahead of the cooldowns that would
rate-limit rows, so logging each one would bury the decisions worth reading.
The status card shows it, and the empty log explains itself and points at
channel delivery.

Also from the verification pass:

- The percent test built its value with config.set and a doubled %, which
  set() is the one input that cannot produce the bug. It now uses read_string
  with a bare %, and I checked it fails against raw=False by substituting
  DEFAULT_MESSAGE — the actual failure mode.
- docs still said the bot identifies itself by public key, contradicting the
  section forty lines above. On this path it is name-only, and that means the
  self-exemption is spoofable; the doc says so.
- delivered_today folded dry runs in with real sends, so a morning's preview
  read as afternoon transmissions. previewed_today is now separate and the
  figure follows the current mode.
- TRIM(MIN(channel)) so a whitespace-padded historical row cannot become the
  display name for a merged channel.
2026-09-16 00:28:33 -07:00
agessaman 4c0f43499e fix(region): close four defects found in adversarial review
**A `global` verdict now requires RF correlated to the message.** It was also
reachable through `_is_confirmed_global_flood`'s argument-from-absence route
("no scope-eligible packet in the window, therefore unscoped"), so a row that
literally said TC_FLOOD, or a scoped ADVERT that the GRP_TXT filter excluded,
came back as "no region code". That inference is fine for deciding whether a
`*` in flood_scopes authorizes a reply; it is not fine for accusing someone of
a misconfiguration. Channel messages still correlate through the payload match
(#255), so the ordinary case is unaffected.

**A `%` in the warning message no longer wedges config reload.** `_get` read
without `raw=True`, so configparser's interpolation raised, the error was
swallowed, and the bot transmitted the default wording instead. Worse, the
bot's own `_validate_config_snapshot` iterates `config.items(section)` and
would reject every hot reload until the file was hand-edited. The save endpoint
now rejects `%` outright and caps the message at 500 characters, and the read
is raw so a hand-edited value is still honored.

**One unreachable sender no longer eats the daily cap forever.** The cap counted
failed attempts but the per-sender cooldown did not, so a node the radio cannot
reach was retried every `min_unscoped_messages` messages indefinitely and no
real offender was ever warned. Both count attempts now. The regression test
fails with three sends against the old filter.

**The sender is a display name, not an identity.** MeshCore's CHANNEL_MSG_RECV
carries no public key, so `sender_pubkey` was always empty on this path and the
only identity is a prefix anyone with the channel key can forge. DM delivery
now requires a contact the radio already holds, which bounds the bot to nodes
it knows and stops failed sends spending cap slots. Two tests asserted the
opposite because the fixture supplied a pubkey the radio never sends; they now
run with what the call site actually passes, and the docs no longer claim
pubkey identity.

Also: a negative `max_warnings_per_day` fell back instead of clamping to the
"unlimited" sentinel; tallies group case-insensitively so a `#` or case change
does not split a channel; retention uses the same clock the rows are written
in; the status figure shows delivered with attempts beside it, rather than a
count of four next to "last warning: none yet"; and the channel bars are scaled
over classified traffic so their width equals the percentage printed beside
them (33.7% was drawn at 28.7%).
2026-09-16 00:14:20 -07:00
agessaman 5ed1a5d1c7 fix(region): honest empty and count-only states on the page
An empty daily-volume chart rendered as an 84px hole with a date range under
it, which reads as broken rather than as no data; it is hidden until there is
something to plot, and the summary says so in words.

"Count only" disabled the rest of the form, which implied those values would
not be saved — they are — and stopped anyone drafting their wording before
turning warnings on. A sentence under the mode selector says it instead.
2026-09-15 22:44:38 -07:00
agessaman d0ec073bb9 fix(region): accessible chart and alert colors in both themes
Bootstrap's .text-warning is #ffc107, which is 1.6:1 on a white card — the
headline percentage and the "daily cap reached" line were close to invisible
in light mode. The chart fills had the same shape of problem in reverse: the
grey "couldn't tell" band sat at 2.1:1 against a white card and 2.9:1 against
a dark one.

Each color is now a token chosen per theme against the surface it lands on:
text clears 4.5:1, fills clear 3:1. Measured in the browser in both themes.
2026-09-15 22:43:12 -07:00
agessaman 4eca8b166e fix(region): show which channel chips are already in the allowlist
An unselected chip looked identical to a selected one, so clicking it was a
coin flip between adding and removing. The chips now reflect the field, in
both directions, and stay in step when the list is typed by hand.

Also re-read after a save rather than patching the form in place (the status
strip's budget is derived from the cap that was just changed), and stop the
reset-to-default button from blanking the message when the page never loaded.
2026-09-15 22:41:06 -07:00
agessaman 0c1aa4fd15 fix(region): never warn the synthetic "Channel User" sender
A channel message with no "Name: " prefix falls back to a stand-in name.
That is not a node: every such message shares the identity, so they would
accumulate a run together and then get a DM addressed to a contact that
does not exist. The hook now passes no sender for those, so they are still
counted but can never earn a warning. The literal is a named constant and a
test pins the hook's guard to it.

Also folds the channel body-budget formula into models.channel_body_limit
rather than letting the web viewer keep a fourth copy of
"max(130, 160 - len(name) - 2)"; BaseCommand and CommandManager now call it
too, and 158 becomes models.DM_BODY_LIMIT.
2026-09-15 22:39:34 -07:00
agessaman 147cc588e1 fix(region): reserve the warning slot before awaiting the send
Recording the decision after the send left an await between the cap check
and the row that check reads. Two channel messages in flight both passed a
cap of one and both transmitted; the new test fails with two sends against
the old ordering.

Every gate and the reservation now run with no await between them, so on the
single event loop they are atomic. A failed send corrects its own row to
'failed' and releases the mesh cooldown, but the row stays and still counts
against the daily cap: a send that reported failure may have put something on
the air before it did.

Also from a self-review pass: bound the per-sender run table (an unbounded
dict on the channel-message path is a slow leak), identify the bot's own node
by public key rather than only by the configured name, and respect
channelpause.
2026-09-15 22:36:29 -07:00