Top-right now holds only the maximize button, and the toggles no longer
sit over the hop labels that cluster near the top of a drawn route.
Bottom-left was the last free corner: zoom is top-left, attribution
bottom-right. Verified at 412x915 and 1440x900, both themes - 10px from
both edges, no overlap with the attribution.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On a phone the map view gives the map a fixed ~45vh strip under the
message list, which is too small to read a multi-hop route on. A control
in the map's top-right corner now expands #paMap over the frame and asks
the parent to hide the modal header, so the map really does get the whole
screen; the same button becomes a labelled Close, and Escape works too.
CSS overlay rather than the Fullscreen API: the panel runs in an iframe
and inside the Android WebView, where requestFullscreen on a plain
element is unreliable. The position rule needs !important — Leaflet
writes position:relative on the container as an inline style.
Verified live at 412x915 and 1440x900, both themes, in the modal, as the
standalone page and through a chat deep link: the map fills the frame,
the drawn route and the panned viewport survive both transitions, and no
console errors.
Co-Authored-By: Claude Opus 5 <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>
The backend already accepted a parent (`region put <name> <parent>`) and
the list indented nested regions correctly, but the Add form always
created at the root. It now carries an "Inside" picker listing the known
regions, defaulting to top level.
Verified end to end against MarWoj Mobile Observer: `zzpar` at the root,
`zzkid` inside it, the tree coming back as `* / pl / zzpar / zzkid` with
the child indented one level, and the firmware refusing to delete a
parent that still has children (`Err - not empty`).
Also fixes a status code this endpoint got wrong from the start. A
repeater answering `Err - not empty` or `Err - unknown region` is
enforcing its own rule, not failing as a transport, so it now comes back
200 with `ok: false` like the sibling /action and /neighbours/remove
endpoints, rather than 502. 502 was misleading and put a spurious error
in the browser console, where it could mask a real one. `dirty` is now
computed from `ok`, so a refused action can no longer mark the map as
having unsaved changes; the frontend checks both through one helper.
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>
Built from 8b5ed4c and run for five hours on a Galaxy S20 FE: 115 wake ticks,
two of which found the socket dead and put it back, and notifications still
arriving at the end. The previous upload predates the wake, so the checksum in
the docs pointed at a build that still went silent after an hour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Built from 9adb98d and verified on a Galaxy S20 FE: installs over the Play
build as an update, keeps the saved address, and the foreground service starts
and stops with the notification toggle.
The Play row in docs/android-app.md deliberately stays at 2.0 - the Store still
serves that until the release passes review.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found on a real device: after Change server, Back did not offer the leave
prompt but reversed into the previous server's pages, leaving the app showing
one instance while it was set to another. The address form is reached through
Back, so switching twice was enough to get there.
The behaviour predates the recent-servers list, but that list is what turns
switching servers into an everyday action rather than a once-a-year one.
Only a deliberate choice of server clears the history - typed in, picked from
the list, or restored at startup. Following a notification into a conversation
still goes back the way it came.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two changes to the Android wrapper, shipping together as app version 2.1
(versionCode 6).
Notifications stopped arriving a few minutes after the app was backgrounded,
and no setting on the phone could prevent it: Android freezes a process with
nothing in the foreground, and a frozen process runs no JavaScript and holds
no socket. MeshWatchService is a foreground service of type remoteMessaging
that does no work of its own - it exists so Android leaves the process alone.
Every notification is still built by the mc-webui page, so mutes, blocked
senders, channel names, translations and deep links stay in one place rather
than being duplicated in Kotlin.
The service follows mc-webui's own notification toggle, read from localStorage
by the shim rather than asked of the page, because the wrapper connects to
whichever server its user typed in - very often an older mc-webui. The shim
also wraps Storage.prototype.setItem, since localStorage fires no event in the
document that wrote it. Android's own switches have the last word, so a revoked
permission or a silenced channel stops the service too.
setRendererPriorityPolicy goes with it: the page renders in a process of its
own, and WebView waives that process's priority once the view stops being
visible, so a service saving only the host process would still lose the
renderer under memory pressure.
Separately, the address screen now lists the servers reached before, so running
more than one instance no longer means retyping the whole address to switch.
Only addresses that answered are listed, which keeps typos out.
Also replaces the deprecated setDecorFitsSystemWindows with enableEdgeToEdge,
which is what Play Console has been flagging twice. Its default navigation-bar
scrim is overridden, or it would paint over the strip the wrapper colours to
match the page below Android 10.
Docs, the privacy policy (both languages) and the app-vs-PWA comparison are
updated: three of them said the app ran nothing in the background.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The wrapper was published to the Play Store today, so three places that told
readers it was not distributed there are now wrong: the Android guide, the
user guide and the README.
Each now leads with the Store and keeps the .apk as the deliberate second
route, with the fact that matters to existing users stated where they will
look for it: both builds carry the same signing key, so installing from Play
updates a repository build in place and keeps the server address.
Historical whatsnew entries are left as written; the change is announced in a
new Unreleased entry instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--global writes to the config of whoever runs it, so a user updating with sudo was still prompted after applying the fix. Confirmed by a user on Raspberry Pi OS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GitHub's HTTP/2 front end began answering the anonymous git-upload-pack
POST with 401 for the git 2.39 / libcurl 7.88 build that ships with
Debian 12. Git reads any 401 as a credential request, so update.sh sat
on "Username for 'https://github.com':" against a public repo, on every
server at once, with nothing changed locally.
Pull with http.version=HTTP/1.1, which GitHub still serves anonymously,
and set GIT_TERMINAL_PROMPT=0 so a genuine auth failure errors out
instead of blocking on stdin forever. Documented in troubleshooting,
README and whatsnew, including the one-off git config for installs too
old to carry the fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Expanding a Settings section fired one `get` per field, each a full mesh
round-trip. Seven questions to a repeater four hops away, answered one at
a time, to change the one field you came for -- and often a batch that
timed out halfway and left the section blank.
Nothing is read until asked for now:
- every field has its own read button beside the control (same row, so
no extra line per field), and `Read all fields` at the top of the
section still does the whole batch
- GET /settings takes an optional `fields=` subset so a single field is a
single round-trip; the section still defines the order
- reads are queued client-side. The device already serializes CLI traffic
behind one lock, so parallel requests would only tie up server threads
waiting for it
- values are remembered per repeater in localStorage, so a section opens
with the last readings instead of empty boxes. A remembered value is a
usable baseline -- Apply writes an absolute value, so editing from a
stale reading still sets what was typed -- and is marked with a clock
per field plus a note at the foot of the section saying when it was
read. Apply and unsupported replies update the cache too
- a failed read no longer wipes what is on screen: the remembered value
stays, still editable, still marked
- field hints moved from a text block under the control into an info icon
by the field name (the pattern the app's own Settings modal uses), and
every field has one now instead of the 9 that did
Verified against a live repeater from the local container: expanding all
8 sections makes 0 requests, a single-field read sends exactly
`?section=advanced&fields=rxdelay`, the value survives a reload from cache
with the stale note, and Apply still round-trips (dutycycle rewritten to
its own value).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Settings -> Advanced carried txdelay and direct.txdelay but not rxdelay,
which CommonCLI has had all along for every repeater and room server
(get + set, float 0-20, 0 = off by default). Reaching it needed a raw
CLI command.
It is a distinct mechanism from the tx delays: calcRxDelay staggers the
repeating of flood packets by packet score, so a strong copy repeats
sooner and a marginal one waits and drops the repeat as a duplicate.
Adds the field to _REPEATER_SETTINGS_FIELDS and SETTINGS_SECTIONS (the
two lists must stay mirrored) plus en/pl labels and help. `af` stays out
on purpose: dutycycle replaced it.
Co-Authored-By: Claude Opus 5 <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>
Covers the kebab menu and the detail dialog in the user guide, the two
new API entries in architecture.md, and a What's New entry under
Unreleased.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Interface tab carried data-demo-lock as a whole, which greyed out the
sidebar layout breakpoint along with the toast settings. The breakpoint is
localStorage — per browser, invisible to every other visitor — so it belongs
with theme, notification permission and the quick-access bar, all of which
were already allowed. The toast settings are server-side and shared, so the
lock moves onto the uiSettingsForm that owns them.
Audited every other locked region against the localStorage key list: this was
the only per-browser control caught by a lock. Notifications, theme, FAB
placement/size/position and the sidebar breakpoint are now the complete set of
preferences a demo visitor may change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
whatsnew.md went in with the fix; architecture.md and troubleshooting.md
did not.
- architecture.md: new section on why one impossible coordinate blanks a
whole map (Leaflet clamps latitude, not longitude), the four places the
check is applied, and why a corrupted `type` is logged rather than
normalised. app/geo.py and geo-utils.js added to the file tree
- troubleshooting.md: user-facing entry for the blank map and the
world-spanning route, naming the two log lines that identify the
offending adverts, and saying plainly that updating is enough
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>
The cherry-pick that replayed them over the 2.12.0 release boundary put both
sections' bullets in the closed 2.12.0 section, which had already been tagged
and published. The 2.12.0 section is now byte-identical to v2.12.0 again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A notification said "New: 1 channel" and nothing more, because
sendBrowserNotification() was driven by badge-counter deltas and never saw a
message. The socket payload has carried the sender, the text and the channel
all along.
Channel messages now notify as "<channel>" / "<sender>: <text>", DMs as
"<sender>" / "<text>", each conversation on its own tag so one cannot bury
another, and repeats on the same tag collapse with a count in the title.
Notifications carry a deep link in options.data.url; a tap resolves it in place
when the page is already up, and the wrapper hands it to the Intent so a cold
start lands there too. Channel links are new (/?channel=<idx>); DM links reuse
the conversation parameter /dm already accepted.
Two faults that made notifications look unreliable while the app was awake:
- A message on the channel last left on screen never notified at all. A channel
now counts as open only while the document is visible.
- The first checkAndNotify() compared against zeroed baselines, so a page
loading in the background announced its whole backlog as new ("New: 492
channels"). The first pass now only takes the measurement.
The count-only notification stays as the fallback for messages the socket never
delivered; the socket path advances its baselines so the same message is not
announced twice.
dm.js's checkDmNotifications() is removed rather than updated: dm.html never
loads app.js, so its areNotificationsEnabled() guard returned on the first line
in every context it ran in, and it would now be a second, vaguer alert for a
message app.js already announces in full.
Verified against the local container with a Playwright harness (14 checks):
content, collapsing, mute and own-message suppression, taps on a running page,
cold starts on both link kinds, and no stray count-only alerts.
Background delivery on Android is a separate, architectural problem and is
untouched here - the wrapper still has no foreground service, so a frozen
process still stops notifications altogether.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Sync clock" under My Repeaters sent the firmware's `clock sync`, which
sets the repeater RTC from the CLI packet's sender_timestamp. The
companion overwrites that field with its own RTC before transmitting
("Use node's RTC instead of app timestamp", companion_radio/MyMesh.cpp),
so the button copied the node's drift onto the repeater instead of the
server's time — observed as a repeater set 56 minutes slow. Send
`time <epoch>` instead: the value travels in the command text, taken
straight from the host clock and untouched in between.
The node's own clock is now checked on connect and hourly, and set from
the host whenever it has drifted more than 30s. It is only ever set by
hand today, so it free-runs: the local test device was 16 hours behind.
CMD_SET_DEVICE_TIME is refused when it would move a clock backwards, so
a fast-running device is logged loudly rather than retried in silence.
Also name the timezone wherever a clock is printed. The Status row
renders in the browser's zone, the console in the container's, and
repeaters answer their own `clock` in UTC — three unlabelled renderings
of the same instant, which is what made this look like chaos rather
than drift.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
On phone-sized screens the floating column overlapped the message
composer and its bottom button landed on top of send, so tapping send
hit the FAB instead. Enough testers reported it to make clear the
default was wrong rather than their aim.
isFabHidden() now treats a missing key as hidden, so only an explicit
'false' shows the bar. The storage key and its meaning are unchanged,
which keeps every browser that already made a choice on that choice:
the new default reaches only browsers that never touched the setting.
The checkbox is inverted to match — "Show Quick Access buttons",
unticked by default — because a negatively phrased toggle that ships
ticked reads as a double negative. Renamed settHideFab -> settShowFab
and the two translation keys with it, en and pl both updated.
With the bar off, applyItemPlacements() already routes every item to
the main menu, verified in a real browser: filter, search, DMs,
contacts and settings are all reachable under the hamburger, the send
button is unobstructed, the choice survives a reload, and a browser
holding 'false' keeps the bar.
Not covered: the direct-message screen has its own FAB container that
does not read this setting. Hiding it there would strand the DM filter
and settings, which have no menu equivalent, so it stays visible for
now and is called out in whatsnew.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A message containing an apostrophe followed by angle brackets — e.g. a bot
answering "Type 'channel <name>' for a description" — tore the action row
below it apart: the apostrophe closed the single-quoted onclick attribute,
the '<' ended the <button> tag, and the remainder ("...")' title="Quote">)
landed on the page as visible text, with reply and quote dead.
JSON.stringify() produced a valid JS literal but was spliced into markup
raw. jsArg() wraps it in escapeHtmlAttribute(), so quotes and brackets
survive as entities and the JS parser sees the literal only after the HTML
parser decodes the attribute. Applied to the quote/edit buttons in channel
chat and DM, and to the sender-name handlers (reply, map, block) that had
the same defect for a name containing an apostrophe.
Verified in a browser against the local container: all ten test messages
now render six clean buttons with no stray text, and quoting '<>' fills the
composer correctly.
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>
The wrapper sources moved to targetSdk 36 and versionName 2.0, but the
`.apk` shipped from this repository was still the 1.3 build, so the
download table described a file that no longer matched the code beside it.
Replaces the published binary and the figures that identify it: 5.2 MB,
version 2.0, and a new SHA-256. Verified before committing - apksigner
reports the APK as signed by the same certificate as every release before
it (SHA-256 425857b3...9bd230), so it installs as an update rather than
forcing a reinstall, and aapt2 confirms versionCode 5 against targetSdk 36.
Also adds the release notes for both Android changes in this cycle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Google Play requires every listing to link a publicly reachable privacy
policy, whether or not the app collects anything. This one is served from
the repository itself, so its history is public alongside the code it
describes.
English and Polish on one page, self-contained and theme-aware like
contact-management.html. The content is drawn from what the app actually
does: nothing reaches the developer, and what stays on the phone is the
server address, the per-host HTTP credentials, the notification-prompt
flag and the WebView's own cache - each listed with the reason it exists.
Permissions get the same treatment, one row each.
docs/.nojekyll is not optional here. GitHub Pages runs Jekyll by default
and docs/architecture.md contains brace syntax Liquid tries to interpret,
which would fail the build and take the policy URL down with it. Serving
the folder as static files avoids the whole class of problem.
Rendering checked in Chrome at 390px and 1000px, light and dark: no
horizontal overflow, tables fit without sideways scrolling.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Opening a channel always landed on the newest message, so catching up on a
busy channel meant scrolling up and hunting for the last familiar message.
Both message lists now draw a divider at the read/unread boundary and open
on it instead of at the bottom.
The boundary is frozen on entry (unreadAnchorTs / dmUnreadAnchorTs) because
displayMessages marks the view read on every render and would otherwise
erase it before it could be drawn. It is recomputed only on a channel or
conversation switch, so resyncs, the geo-cache re-render and the 60s DM
refresh keep the divider in place rather than making it vanish a second
after arriving.
No divider when there is nothing to catch up on, on a first visit
(last_seen 0) or in the archive view. When every loaded message is unread
the divider renders first but the view stays at the bottom — the list
already starts there and scrolling would imply history above it.
Also fixes two scroll-position bugs this exposed:
- DM displayMessages scrolled to the bottom unconditionally, so the
periodic refresh yanked anyone reading history. It now honours a
dmIsUserScrolling flag, matching channel chat.
- Sending resets the flag in both views; without it a user parked at the
divider would send into a part of the list they cannot see.
Verified in Chrome against the local container across light/dark and
desktop/mobile: boundary lands between the anchor and the next message,
divider spans the full width in both flex lists, filter hides it, and an
incoming message leaves the view parked while an own send drops to bottom.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
user-guide gains two sections: how a received card behaves and what each
button state means, and how the "+" button shares something.
architecture gains "Share Tokens & Cards" — the three wire formats with
the byte arithmetic that explains why contacts use a compact token rather
than the URI, why parsing has to run before HTML escaping, the per-page
hooks the DM iframe needs, and two traps worth not rediscovering: never
compose an i18n key (a composed key hides a missing translation from
i18n_check.py — that is how share.card.private_channel shipped missing),
and cards belong in .message-content, never .message-meta.
whatsnew describes both halves for users, in the release voice.
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>
iOS WebKit zooms the whole page when a focused form control computes to
under 16px. `#messageInput` was 0.9rem (14.4px) on narrow screens, so
every tap on the message box tripped it - reported on iPhone in both
Safari and Chrome, on a phone with no configuration of its own.
The zoom also never undid itself: the shell is `height: 100dvh;
overflow: hidden`, so WebKit has nothing to scroll back and leaves the
page scaled until the user pinches out.
Raise every focusable control to 16px on (pointer: coarse), which also
covers landscape phones wider than the 768px breakpoint. Desktop keeps
Bootstrap's smaller sizing. The guard uses !important on purpose: it has
to outrank page-local <style> blocks that load after style.css and reach
specificities like `.filter-sort-toolbar .form-select`.
Fields that only open a picker (checkbox, radio, range, color, file,
buttons) are excluded - they never trigger the zoom.
The contacts toolbar was the one layout that could not take the bump:
three 16px selects overflow one unwrapped flex row, clipping the sort
control, so it now wraps.
Verified locally at 390x844 and 932x430 with touch emulation: no control
under 16px on any route, no horizontal overflow, desktop unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tapping the navbar channel selector focused a text input, so Android
raised the on-screen keyboard on every channel switch — half the screen
covered for a list that is four or five items long. The type-to-filter it
enabled solved a problem nobody has: the firmware caps how many channels
a device holds, so there are never enough to search.
#channelSelectorInput becomes #channelSelectorButton, a <button> carrying
the same .form-select chrome, and click toggles the dropdown. The list
itself is unchanged (previews, unread badges, favourites, muted styling),
so renderChannelDropdownItems() just loses its query parameter and the
channels.dropdown.no_matches key goes with it. Keyboard access is kept:
Enter/Space opens, arrows move, Enter selects, Escape closes — the
keydown handler now bails out while the dropdown is closed so Enter
performs a plain button activation instead of picking the first row.
Verified locally at 360px: focus lands on the button (never an editable
element), open/toggle/select/outside-click/Escape all behave, 0 console
errors, and the control renders identically in light and dark themes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The path dialog could send a node back to FLOOD or push a configured
path, but Direct — the firmware's third mode, zero hops — had no button;
getting there meant clearing the path from the Console.
A new POST /api/contacts/<key>/paths/set_direct sends an empty path hex,
which makes the firmware store out_path_len = 0, the state
_format_path_display() already renders as "Direct". Configured paths are
untouched, same as Restore FLOOD.
The button is wired in both places paths are managed: the DM contact info
dialog and the My Repeaters path modal. Both button rows get flex-wrap so
the third button wraps instead of overflowing at 360px.
Verified against the local test device: the endpoint moved a repeater
from 5E34→EE6C to Direct and back, and both dialogs render correctly in
en/pl at 1280px and 360px.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Restarting the node meant opening the Console and typing `reboot`, or
reaching the hardware. Device > Info now carries the same action as a
button, with a confirm that says what it costs: the device leaves the
mesh for a few seconds and anything addressed to you in that window can
be lost.
POST /api/device/reboot wraps the DeviceManager.reboot_device() the
console command already used. The firmware acknowledges nothing and the
companion link goes down with it, so a 200 means "command written", not
"device is back" - the reconnect is _on_disconnected()'s job, and it
takes about five seconds over TCP.
The button is rendered by loadDeviceInfo() rather than sitting in
base.html, so it only exists on the branch where the device answered.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drop the -dev suffix and date the pending whatsnew section. Also record the
two repeater path-wrapping fixes (31729fd, 8224128), which landed after the
docs pass and had no user-facing entry yet.
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>
Also documents the two behaviours the release adds to Resend that only
whatsnew had covered: the stale-message confirmation, and that a resend
now repairs a message left with no delivery badge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tab read fields the firmware never sends. get_stats_core/radio/packets
return uptime_secs, queue_len, tx_air_secs, rx_air_secs and recv; the tab
looked for uptime, queue_length, tx_air_time, rx_air_time and received, and
every one of those rows silently dropped out - leaving battery, errors and
Packets TX next to the database counters. The `stats` console command reads
the same payload with the right names, which is why it showed more.
Fix the names and add what was never shown at all: last RSSI, last SNR, noise
floor, the flood/direct split on both packet counters, RX errors, and channel
utilization. The rows now sit in four titled groups - System Information,
Radio Statistics, Packet Statistics, Database - reusing rptmgmt.status.* for
the labels the repeater status view already had, so the local device and a
remote repeater read the same way. Airtime becomes a duration ("8h 31m")
rather than minutes, for the same reason.
Only device.stats.database is a new key. The user guide's description of this
tab listed free memory and a forwarded counter, neither of which exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Raw resend relies on Mesh::hasSeen at the repeaters to suppress the copy,
but that table only holds recent packet hashes. Resending a week-old
message therefore propagates as if new — observed 2026-08-07, when test
resends of a 30-Jul and a 31-Jul message reappeared on every node's #test
dated today, and merged both flights of echoes onto one badge.
Own message bubbles now carry data-timestamp, and resendChannelMessageRaw
warns (with the message's age) past one hour. Missing timestamp means an
optimistic bubble, i.e. fresh, so it never asks.
Verified in Chrome with the POST intercepted: 8 days old warns and
dismissing sends nothing, 59 min sends straight through, 61 min warns in
Polish too.
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>