Firmware 1.18 gives contact flag 0x10 a security meaning (the contact may
run any companion CLI command over the mesh, unseen by the app), forwards a
refused remote command as a txt_type 3 DM, and serves the companion CLI over
the app link (CMD_RUN_CLI_COMMAND, fw_ver_code 14).
- Remote CLI permission is visible: red badge on the contact card and in
the DM contact-info window. Console change_flags shows flags in words,
accepts 0x.., reports old/new, warns on grant, restores the in-memory
record when the device refuses, and invalidates the contacts cache.
- txt_type 3 DMs are stored with their type, rendered as "Remote CLI
command - not executed" with the command verbatim, logged at WARNING,
and the notification says so.
- New console command `cli <command>`, gated on fw_ver_code >= 14 and on
the library having run_cli_command (2.3.9+, requirements untouched).
poweroff/shutdown refused; reboot and `set name`/`set path.hash.mode`
routed through the existing commands so caches follow.
- Re-adding a contact the device holds (a rename) starts from the device
record, so its flags and path survive instead of being reset to zero.
- Fix: approving a pending contact raised KeyError('out_path') for every
record not produced by a NEW_CONTACT event (advert-injected entries and
the DB fallback). Records are now completed before add_contact(), and a
device ERROR is reported instead of a false success.
- set_channel refuses a named slot with an all-zero key.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Renaming the device in Settings changed the firmware's node name at once,
but mc-webui kept its cached copy until the next reconnect. Own messages
were stored under the old name, and their raw_packet snapshot and echo
candidates were built from it: Resend then broadcast a second packet under
the old name, and messages sent after the rename never got a repeater
badge. Open pages kept the old name in the navbar and on each message
sent, even across a container restart, until a reload.
set_param('name') now cuts the name to the firmware's 31 bytes on a
character boundary (the firmware cuts mid-character, the lib drops the
fragment on read-back), reads SELF_INFO back under the send lock - also
when the command failed or its reply was lost - and adopts the reported
name through _set_device_name(), which _connect() uses too. That updates
runtime_config and emits device_name on /chat; the page applies it to the
navbar, MC_CONFIG.deviceName and the mention rule, and loadStatus() catches
up from /api/status for a page that missed the event.
Also: send_channel_message reads the name under the send lock; the
self-echo guard no longer compares the sender's name, so a resend of a
message from before a rename is still recognised; get_device_info no
longer caches an ERROR reply as SELF_INFO; the observer reloads its MQTT
clients on a rename; Settings sends the name only when it changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The meta line under an incoming channel message used to print the first
route inline ("Route (3): 142C→1122→271C→EE6C"), which with 2- or 3-byte
hashes and a few hops crowded everything else off a phone screen. It now
names only the per-hop hash size ("Route (3): 2 bytes") and stays the tap
target for the routes popup, which lists every route as before.
The line also ends with "Region: <name>" when the packet was flood-scoped
to a region this instance knows. The region name never travels: a
TRANSPORT_FLOOD packet carries a 16-bit transport code (HMAC of the scope
key over the payload) that _on_rx_log_data used to discard. Echoes now
keep those 4 header bytes in a new echoes.transport_codes column, and the
API recomputes the code for every configured region (Settings → Channels)
to find the one that reproduces it; own messages fall back to their
raw_packet snapshot. calc_transport_code() is shared with the raw-resend
packet builder, so both sides derive the stamp from one place.
Verified against on-air packets from the test server on #mcwebui: pl,
pl-ma and pl-kra each resolved to exactly one configured region.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adds "Move to another region" to the per-region menu. Until now the only
way to re-parent was to delete the region and add it again under a new
parent. `region put <name> <parent>` on an existing name already
re-parents it (RegionMap::putRegion), so the command was there; what was
missing was doing it safely.
Two firmware behaviours make a blind put unsafe, both confirmed live on
MarWoj Mobile Observer, and both settled by the one tree read the move
now does up front:
- The only cycle check is `region->id == parent_id`. Moving a region into
one of its own descendants is accepted, and detaches that branch from
the wildcard root: it disappears from the `region` listing entirely
(printChildRegions recurses down from the root) while still holding
table slots. Verified — after `region put zza zzb`, both vanished from
the tree while `region get zza` still answered ` zza (zzb) F`. Nothing
in the UI could then show them, let alone fix them. Forwarding is not
at risk: findMatch scans the flat array and never walks parents.
- The put branch sets `region->flags = 0`, so moving a flood-denied
region silently re-allows flood, and the reply says
`OK - (flood allowed)` without hinting anything changed. Verified: zzb
was denied before the move and came back allowed.
So the move reads `region` first, refuses self/descendant/wildcard
targets, then puts, then re-applies `denyf` when the region was denied.
A lost restore is surfaced rather than swallowed — the region has moved
but is forwarding traffic it was set to drop. The picker filters the same
descendant set client-side, so the guard is not a surprise at the end.
Moving a region carries its nested regions with it, which is the point:
verified moving zzc (holding zzb) under zza.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A repeater's flood-scope map was only reachable by typing `region`
commands into the console and reading back an indented text tree.
My Repeaters -> Settings -> Regions now renders that tree as a list with
per-region actions, an Add form, and a Default scope card.
Placed as a row at the foot of the Settings pane rather than a seventh
tile: region management is a list with its own add/remove and an
explicit save, not the scalar get/set fields the accordion is built for.
openToolPane() already accepts any descriptor, so REGIONS_TOOL reuses
the pane machinery; _paneCurrent/_paneParent make the pane back arrow
return to Settings instead of jumping two levels to the grid.
Two firmware facts shape the design, both verified against a live
repeater (MarWoj Mobile Observer, 2.2.15):
- put/remove/allowf/denyf mutate the map in RAM only and are lost on
reboot until `region save` writes flash. Edits therefore stage behind
an explicit Save with a warning banner, and leaving with changes
pending asks first. `region default` is the exception -- the firmware
calls saveRegions() itself -- so it never sets the dirty flag.
- The bare `region` reply is an indented tree (indent = depth, `^` =
home, trailing ` F` = flood allowed) capped at 160 bytes by
exportTo(reply, 160). Suffixes are stripped right-to-left because a
region may legitimately be named `F` or end in `^`, and a clipped
tail is reported rather than hidden.
The binary AnonReqType.REGIONS request stays unused: it answers with
exportNamesTo(), a flat list of allowed names with no hierarchy, deny
flags or home marker -- most of what this screen shows.
Names go through the existing is_valid_region_name() before any command
is built, which also blocks CLI injection through a name.
28 new unit tests cover the tree parser (nesting, the `F`-named-region
ambiguity, `#` stripping, truncation) and the command builder.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A channel used to have two settings: every message or nothing. Now it has
three - muted, every message, or a named profile of rules (mentions me,
message contains, sender contains; any or all must match). A channel on a
profile counts only matching messages: the unread badge, the navbar bell,
the app-icon badge and the browser/Android notification all follow the same
rule. Direct messages are untouched.
- app/notify_profiles.py: rule matching (case- and diacritic-insensitive,
same table as the chat filter), validation, storage in app_settings
- read_status.notify_profile column, written together with is_muted so a
mute never leaves a profile behind; migration added
- /api/notification-profiles CRUD, PUT /api/channels/<i>/notifications;
/api/read_status and /api/messages/updates report channel_notify_profiles,
the latter counts a profiled channel's unread through the profile
- Manage Channels: the bell opens a menu (Off / Every message / profiles /
Manage profiles...) instead of toggling; profiled rows get a green badge
- Settings > Notifications: profile list with in-use counts and an editor
- app.js evaluates the same rules for socket-delivered messages, and a
state version guard stops an in-flight poll from undoing a fresh
mute/favorite/profile change
- i18n en/pl (43 keys), docs (user guide, architecture, whatsnew), tests
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The path list is a retry order, so the same route twice means retrying a
route that has just failed while a genuinely different one waits. Both
editors (contact info and My Repeaters) POST to the same endpoint, so the
guard sits there: adding or editing a path onto a route the contact
already has is refused with 409 duplicate_path, and the modal stays open.
Paths are now normalized to lowercase on write and existing duplicates are
collapsed on startup (keeping the primary copy), which lets a unique index
back the check. The index is created in the migration rather than
schema.sql, which runs before duplicates are removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A tester's cached-contacts map came up empty and Path Analyzer drew routes
across the globe. Two of his 2131 cached contacts carried garbage positions
from corrupted adverts - lat 1642 / lon -323 and lat 309 / lon -1768.
Leaflet clamps latitude when projecting but not longitude, so fitBounds()
spanned 1797 degrees, dropped to zoom 0 and centred on lon -870; all 2273
real markers were rendered several world-widths outside the viewport.
Reproduced against his DB: zoom 0, bounds -1431..-309. The device-only view
was fine because both bad rows are cache-only.
Four layers, so no single miss can blank a map again:
- app/geo.py: one definition of a usable coordinate, applied on write in
Database.upsert_contact() so the cache stops storing garbage, and on API
output so a cache poisoned by an older build heals itself with no
migration. Corrupted advert types (61, 126, 152, 210 seen in the wild)
are logged but kept raw - they already render as UNKNOWN.
- static/js/geo-utils.js: hasValidGps()/withValidGps()/fitMapToPoints()
replace eight copies of the same check across six files. Path Analyzer
had a looser variant that also accepted lon == 0, which put nodes off
west Africa and stretched routes across the map; it now shares the rule.
- Per-marker try/catch and a bounds validity check, so an entry that still
slips through costs its own pin rather than the whole map.
Verified against the tester's DB in a container: cached map 2273 markers,
lon span 1797 -> 19 degrees, centred on Poland; the route that drew the
world-spanning line (msg 29499, token 72AF) now renders as a real
Krakow-Kielce path with the bad hop left unresolved. Live local instance
regression-checked on all five map pages. tests/test_geo.py: 43 passed;
full suite 290 passed, 2 pre-existing failures unrelated to this change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mc-webui has no login, which is fine on a LAN and not fine once the
address is handed to testers: they assume the node is theirs and rename
it, and the console is right there for anyone curious enough to type
into it.
MC_DEMO=true refuses everything that reconfigures the radio, destroys
data, reaches other people's repeaters, rebuilds the containers,
publishes to an external broker, or hands out a secret. Reading and
sending messages keep working, so the instance is still worth visiting.
The guard is default-deny: an api_bp.before_request hook refuses every
non-GET unless it is on a short allowlist, so an endpoint added later is
locked without touching this module. That mattered — six mutating
endpoints already had no frontend caller and would never have made a
hand-written blacklist. A small read denylist covers the GETs that leak
(repeater passwords, backup download, capture download, /api/logs),
which the write guard never sees.
Two paths bypass the HTTP layer and are closed separately: the console
is a Socket.IO event, not a route, so it is checked against a read-only
command set; the log viewer is refused at the connect handshake.
Access is kept two ways. MC_DEMO_TRUSTED_NETS unlocks by CIDR, and
MC_DEMO_UNLOCK_CODE unlocks by a code typed once in Settings and held in
an HMAC cookie for 30 days — stateless, and deliberately not Flask's
session, whose SECRET_KEY is a hardcoded public constant.
The trap the network list walks into: published through a tunnel, every
visitor arrives from the tunnel's address, and if the tunnel runs on the
LAN then trusting the LAN unlocks the instance for the whole internet.
An address arriving with a proxy header is therefore not matched unless
MC_TRUST_PROXY is on, and startup logs report the resulting shape.
Frontend is advisory only: demo-lock.js loads from _head_i18n.html so it
reaches all eight entry points including the six iframes, greys out
[data-demo-lock] rather than hiding it (the instance exists to show the
app off), re-applies via MutationObserver for JS-rendered buttons, and
turns any 403 demo_locked into a toast.
Verified on the local container: the rename, reboot, updater, vacuum,
cleanup and the leaky GETs all 403; sending, read markers and ordinary
reads pass; `set name` is refused over the console socket while `infos`
still answers; unlock/lock round-trips. 86 new unit tests, en/pl at 100%.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Firmware v1.17 changed no protocol constants and left FIRMWARE_VER_CODE at
13, so the companion protocol cannot tell a v1.16 node from a v1.17 one.
Capability is instead read from the CLI reply text, which self-describes and
works for any version and board without an extra round-trip:
`??: <field>` / `unknown config: <field>` → firmware has no such setting
`Error: unsupported` → hardware cannot do it
`> <value>` → supported
Both of the first two now yield an 'unsupported' status rather than a
failure, rendered as a muted badge with the control locked and excluded from
Apply. Without this, every field added below would read as a red error on
each repeater that predates it.
Applying to an unsupported field deliberately asserts nothing about the
stored value: v1.17 persists radio.rxgain before it learns the radio cannot
apply it, so the old value may or may not still hold. The baseline is
dropped instead of being reasserted, and only Refresh reveals the truth.
Also guards a pre-existing trap: up to v1.16 the `radio.rxgain` getter was
compiled out on non-SX126x radios, and `get radio.rxgain` fell through to
the `radio` branch (its memcmp has no trailing space), answering with the
radio parameters. That parsed as a valid reply and silently rendered the
switch as off. An on/off field that answers neither on nor off is now
treated as absent.
New fields: cad (v1.17+), flood.max.unscoped and flood.max.advert (both
present since v1.16, previously just undocumented upstream).
New action: poweroff (v1.17+). Unlike reboot it cannot be undone over the
mesh, so it takes a typed repeater name rather than a confirm(), checked
server-side as well as in the UI.
Telemetry: the MCU temperature v1.17 appends to channel 1 is labelled as
silicon rather than as an environmental reading. Absent stays absent —
boards without a usable sensor emit nothing, and a zero would be a lie.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Some faults cannot be diagnosed from a screenshot on a server we have no
access to. The one that prompted this: a sent channel message gets no
repeater badge, and nothing in the database distinguishes "nobody in
earshot repeated it" from "the repeat came back over the air but the
firmware dropped the RX-log frame before the app ever saw it".
Settings > Diagnostics records a bounded window into a zip: every RX-log
frame, the echo correlator's decision (with the pending-send list that
explains a non-match), what each send expected to hear back, the
application log at DEBUG, and the device's own packets.recv counter at
both ends of the window. That last comparison is the whole point - it
turns the firmware's silent 4-frame queue overflow into a measurement
instead of a hypothesis.
scripts/diag_report.py reads a capture offline and prints the verdict.
Captures can be downloaded, or uploaded to a Zipline instance once the
user pastes a token handed to them out-of-band. The URL ships as a
default; the token never does, because this repository is public.
- Hot path follows observer.py's contract: one deque append, no locks,
no I/O. The log handler uses a no-op lock object rather than
lock = None, which raises under Python 3.13's `with self.lock` in
Handler.handle() - latent on the 3.11 container, fatal on 3.13
- Caps: 5/15/30/60 min, 25 MB, 10 stored captures. All enforced by the
writer thread, so a user stop and an auto-stop finalise identically
- The capture contains the text of messages received while it runs; the
UI states this and confirms before any upload
Also drops a redundant function-local `from flask import current_app` in
get_status(): it shadowed the module-level import for the whole function
and broke /api/status as soon as anything referenced current_app above
it. Found by the capture tool, in its own change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A sent channel message learns its pkt_payload — and therefore its route
badge, repeater list and Analyzer link — only by hearing its own echo come
back. Correlation kept a single _pending_echo slot, so any second send or
resend overwrote it and the first message's echo was filed as foreign
traffic. Proven on prod: msg #71179 lost its badge because a resend of
#71177 fired 33 ms before its echo arrived. 16 of 471 own messages over 30
days had no payload; 3 of those were correlation failures (this bug, plus
echoes arriving 62 s and 200 s after the send, past the 60 s expiry).
- _pending_echoes: one entry per message, capped at 32, so concurrent sends
no longer evict each other
- exact-payload matching stays valid for 5 min (repeats do arrive minutes
late); the loose channel-hash fallback keeps its 60 s window because it
can grab a foreign message on the same channel
- resend re-arms from _payload_from_raw_packet() when the message has no
pkt_payload yet — previously it re-armed only when a payload already
existed, which is precisely the case that never needs repairing
Verified live against the test device: reproducing the exact prod sequence
(send, then resend an older message 0.4 s later) now correlates both, and
resending a badge-less message recovers its badge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Introduces the SQLite-backed region registry and channel->region mapping
that will drive the per-channel flood-scope feature. No UI or device
wiring yet; those land in subsequent PRs.
- schema.sql: new `regions` and `channel_scopes` tables + partial index
on the default flag.
- database.py: CRUD helpers for regions (create/list/get/delete/default)
and channel_scopes (set/get/bulk-load) with ON DELETE CASCADE.
- app/meshcore/regions.py: pure helpers for SHA256('#'+name)[:16] key
derivation and firmware-compatible name validation (mirrors the
`RegionMap::is_name_char` rule `c in {-,$,#} or c>='0' or c>='A'`).
- tests/test_regions.py: known SHA256 vectors, validator coverage
(incl. the firmware quirk that `_` and other 0x5B-0x60 chars are
admitted), and CRUD + cascade integration tests.
Backup filenames now derive from the active DB stem (e.g. mc_9cebbd27.2026-03-24.db).
Listing and cleanup glob *.db so existing mc-webui.* backups remain visible.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
All settings (protected_contacts, cleanup_settings, retention_settings,
manual_add_contacts) moved from .webui_settings.json file to SQLite database.
Startup migration auto-imports existing file and renames it to .json.bak.
Added safeguard in _on_new_contact: if firmware fires NEW_CONTACT for a
contact already on the device, skip pending and log a warning. Also added
diagnostic logging showing previous DB state (source, protected) when
contacts reappear as pending.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reads the existing .msgs JSONL file and imports channel messages and DMs
into the v2 SQLite database. Runs automatically when device connects and
DB is empty. Handles sender parsing, pubkey resolution, and FK constraints.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add pkt_payload column to direct_messages table for stable packet
hash generation and Analyzer URL linking
- Update insert_direct_message() and DeviceManager to store pkt_payload
- Add test for DM pkt_payload storage (43 tests pass)
- Update watchdog to monitor only mc-webui (meshcore-bridge removed)
- USB reset trigger now fires for mc-webui container failures
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>