623 Commits

Author SHA1 Message Date
MarekWo d16093b459 docs: add whatsnew.md release notes for the upcoming main merge
User-facing summary of changes since fd2b3d0 grouped into New features
(custom analyzers, apply-path button, DB Optimize button, auto retention,
sluggish-device watchdog), Reliability & polish (polling-only fix for
load freezes, channel cache, scope-key refresh, multi-byte path
rendering, TCP self-heal, region scope on slots >7, console parser),
and a deploy note about restarting the host watchdog. Also linked from
user-guide.md's Getting Help section. File is meant to be refreshed
before each merge to main.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 12:04:19 +02:00
MarekWo 53ef2759d5 docs: cover analyzer settings, vacuum/optimize, path apply, watchdog soft patterns
User-guide: new Settings > Analyzer tab (custom analyzer services with default/disabled
toggles and {packetHash} placeholder), apply-path upload button in DM Path Management,
Backup modal's Optimize button + live size label, console change_path now accepts
arrow/whitespace separators with consistent multi-byte chunk length and "path" output
shows hop count and byte size.

Architecture: new /api/analyzers CRUD + default endpoints, /api/db/size and the split
/api/db/vacuum kickoff + /api/db/vacuum/status polling (worker-thread VACUUM to survive
proxy idle timeouts), /api/contacts/<key>/paths/<id>/apply, /health and /health/strict
top-level routes, analyzers table and direct_messages.delivery_path_hash_size column,
recombined path_len byte storage. DeviceManager: per-send channel-secret refresh,
liveness telemetry (_last_rx_at + _consecutive_stats_failures), TCP self-heal via
_liveness_watcher_loop + in-place reconnect. Retention scheduler: on-by-default
90/90/60/30, post-cleanup VACUUM at >=1000 deletions, app-context wrapping, archiver
emoji-name fallback. Socket.IO clients forced to polling transport.

Watchdog: documented hard- vs soft-pattern detection (5 hits in 2 min for sluggish
get_stats / get_battery failures), pointer to /health/strict, and the systemd-restart
deploy note for scripts/watchdog/ changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 11:53:41 +02:00
MarekWo 7e9ff2e3aa fix(channels): drop hardcoded 0-7 limit on set-scope endpoint
The /api/channels/<idx>/scope route rejected idx>=8 with "Channel index
out of range (0-7)" even though current firmwares expose up to 40 slots
(send_device_query reports max_channels=40 in our logs). Users couldn't
set a region scope on channels like #ubot (idx 8) or #swietokrzyskie
(idx 15) — the UI showed the modal but Save returned 400.

Use device_manager._max_channels (set from send_device_query at connect)
as the upper bound, with 8 as a safe fallback if the DM is unreachable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 11:06:30 +02:00
MarekWo fef6845c03 fix(connection): self-heal degraded long-lived TCP via in-place reconnect
Long-lived TCP against the meshcore-proxy can degrade in a way the socket
can't see: some commands (set_flood_scope_key with all-zero key) start
timing out while RX events and other commands keep working. The 5 s
execute() timeout fires with concurrent.futures.TimeoutError() — whose
str() is empty — so the UI showed "Could not set region scope (none):"
with no error text, and only channels with a mapped region could send
because their non-zero scope_key happened to keep working.

Two recovery paths:

- send_channel_message now detects the timeout case (set_flood_scope_key
  surfaces timed_out=True) and runs force_reconnect() + one retry before
  failing. The user sees a brief delay instead of a cryptic error and
  having to restart the container.

- A new _liveness_watcher_loop task runs on the DM event loop and forces
  a reconnect when no RX event has arrived for HEALTH_STRICT_MAX_RX_STALE_SEC
  (5 min). /health/strict now also reports rx_stale for TCP (previously
  serial/USB only), so an external watchdog could act on it too.

force_reconnect() runs on the DM loop via run_coroutine_threadsafe with
a 20 s cap, a 30 s cooldown to avoid churn under fire, and a
_reconnect_lock to prevent concurrent attempts. mc.disconnect() fires
DISCONNECTED — _intentional_disconnect tells _on_disconnected to skip
its own reconnect loop so the two don't race.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 21:10:03 +02:00
MarekWo f1477d84ac fix(db): run VACUUM in a worker thread to survive proxy timeouts
The reverse proxy fronting mc.wojtaszek.it closes idle HTTP responses
after ~30 s, so the manual Optimize endpoint timed out client-side
even though SQLite finished VACUUM and the Flask handler logged a 200.
The user saw "Optimize failed" while the DB had actually shrunk.

Split the endpoint into kickoff + polling: POST /api/db/vacuum spawns
a daemon worker thread, stores state in a module-level dict guarded by
a lock, and returns 202 immediately. GET /api/db/vacuum/status returns
{running, elapsed_seconds, ...} so the UI can poll every 2 s and show
the same "freed X bytes in Y s" toast once the worker is done. A
second POST while a VACUUM is in flight returns 409 instead of starting
a parallel rewrite.

Client polls for up to 10 minutes (300 × 2 s) before surrendering with
a "still running" warning — well past any real VACUUM duration we'd
expect, but bounded so a server-side crash can't leave the UI
spinning forever.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 12:30:09 +02:00
MarekWo 13a650bb6c fix(channels): read channels from DB instead of iterating device slots
The TimeoutError-based fallback added in 1d47c9c only fires when
mc.commands.get_channel() actually raises — but on a sluggish device the
call returns an empty/falsy event without raising, so the loop walks
all dm._max_channels slots (40 on the firmware in production), each
empty result returns None, and the API yields just Public (or whatever
slot 0 happened to succeed on). The DB fallback never triggered and the
user kept seeing just Public after refresh.

The channels table in the DB is already the authoritative cache:
- _load_channel_secrets() syncs it on every device connect and prunes
  stale rows,
- set_channel()/remove_channel() update it synchronously with the
  device,
- _refresh_channel_secret() refreshes individual rows on per-send
  refresh.

Drop the device-slot iteration in cli.get_channels() and read from the
DB. /api/channels response time becomes a single SELECT (<1 ms) and is
unaffected by device responsiveness — exactly what we wanted from the
fallback in the first place.

Also revert the TimeoutError re-raise in get_channel_info(): the
console `channels` and `add_channel` commands iterate slots and would
crash on the first slow one. Logging + None on failure is the right
behavior for slot iteration. The 3 s default timeout stays since it
still keeps individual slot probes cheap.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 12:21:34 +02:00
MarekWo f72f6d418a feat(db): VACUUM after retention and an Optimize button in Backup modal
SQLite DELETE marks pages free but doesn't shrink the file, so the
new retention job would keep DBs at their bloated size forever without
a follow-up VACUUM. Add db.vacuum() that runs PRAGMA-free VACUUM and
reports size_before/size_after/elapsed so callers can surface results.

The retention job now calls vacuum() automatically when it deleted at
least 1000 rows. Threshold avoids the multi-second VACUUM cost on quiet
days. Failure is logged, not raised — a missed VACUUM never crashes
the scheduler.

Power-user override: new "Optimize now" button in the Database Backup
modal triggers VACUUM on demand via POST /api/db/vacuum, alongside a
GET /api/db/size that drives the live "Current size" label. This way
users don't have to wait until 03:30 to reclaim space after the first
big retention pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 10:55:23 +02:00
MarekWo 422e7a3b34 feat(watchdog): catch sluggish-device failures via soft-pattern counting
The container watchdog only restarted on three legacy "device clearly dead"
log lines, so today's failure mode (firmware briefly stalls and get_stats_*
/ get_battery commands time out with an empty error while passive RX
keeps working) never tripped it — leaving the user with 10-15 s freezes
several times a day and no automatic recovery.

DeviceManager now tracks two liveness signals:
- _last_rx_at, bumped on every RX_LOG_DATA event
- _consecutive_stats_failures, incremented on get_stats_* / get_bat
  exceptions and cleared on success

New /health/strict endpoint exposes these to the watchdog. It returns 503
when the device is connected but has 5+ consecutive stats failures, or
when no RX event has been seen for over 5 minutes on a serial transport.
The cheap /health endpoint keeps its lenient behavior so Docker's
healthcheck doesn't suddenly start tripping.

The watchdog's check_device_unresponsive() gains a "soft" pattern class
with a count threshold of 5 in the last 2 minutes — matching against
get_stats_core/radio/packets failed:, Failed to get battery:, and
Failed to get channel. Hard patterns still trigger on a single hit.

Deploy note: the watchdog runs as a host-level systemd service and is
NOT restarted by mcupdate, so after deploy run:
  sudo systemctl restart mc-webui-watchdog.service

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 09:43:43 +02:00
MarekWo 63204fe08d fix(retention): enable retention by default and unblock the scheduler
The retention/cleanup scheduler was throwing "Working outside of application
context" on every boot because init_*_schedule() and the APScheduler jobs
themselves call api.get_*_settings(), which reads current_app.db. Pass the
Flask app into archiver.manager via set_flask_app() and decorate every
scheduled job with @_with_app_context so the context is active when the
worker thread runs. Wrap the init calls in main.py in app.app_context() too.

Extend cleanup_old_messages() to also delete from echoes, paths and acks
(the diagnostic tables — 191k echo rows account for the bulk of the 85 MB
DB). Each diagnostic table has its own retention window, defaulting to
min(days, 30) so debug data is purged on a tighter schedule than
user-visible messages.

Switch RETENTION_DEFAULTS to enabled=True with 90/90/60/30 days for
channel_messages/DMs/adverts/diagnostics; user opted in explicitly. First
run scheduled for 03:30 local. SQLite DELETE only marks pages free —
file size won't shrink until a manual VACUUM is run separately.

Archive job stopped working the day the device was renamed to include an
emoji ("MarWoj 💡"): the meshcore library strips non-ASCII when writing
the .msgs file, but our archiver still built /data/{device_name}.msgs and
hit ENOENT every night at 00:00. Add a tolerant fallback that globs the
data dir for a single non-archive .msgs file when the expected path is
missing — mirroring how migrate_v1 already handles this.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 09:41:07 +02:00
MarekWo 1d47c9c0e8 fix(perf): polling-only Socket.IO + channels DB fallback on USB timeout
Werkzeug dev server can't upgrade WebSockets, so every io() upgrade attempt
returned HTTP 500 and clients fell into a polling/upgrade reconnect loop —
visible as 10-15s freezes on app load. Force transports: ['polling'] on
/chat, /console and /logs clients; long-poll keeps real-time pushes
working with ~1-2s latency.

When the MeshCore device briefly stalls, get_channel_info() used to block
on the default 30s timeout per slot, so iterating max_channels slots could
take minutes; in practice only Public answered and the rest timed out,
leaving the UI with just one channel. Drop per-call timeout to 3s, raise
TimeoutError to the caller, and have cli.get_channels() break on first
timeout and merge the remaining slots from the channels table in the DB
(which already mirrors device state via upsert_channel).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 07:31:47 +02:00
MarekWo f34c95c15b fix(analyzer): dim Settings backdrop behind nested analyzer modals
Bootstrap stacks backdrops at z-index 1050, which falls below the open
Settings modal at 1055. Without bumping it, the Add analyzer and chooser
modals appeared without a visible backdrop. Mirror the coordPickerModal
fix by raising the latest backdrop to 1075 on shown.bs.modal.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 21:35:23 +02:00
MarekWo f0cb354ae0 fix(analyzer): wrap long URLs in Settings list on narrow mobile viewports
Without min-width:0 on the flex-grow column and explicit word-break on
the <code>, a long URL with no spaces would refuse to wrap on real
Samsung S20 / Chrome Mobile, pushing the switch and edit/delete buttons
off-screen. DevTools mobile emulation hid the bug because its <code>
wrapping defaults differ slightly from the real device.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 21:20:54 +02:00
MarekWo 10792b8566 feat(analyzer): add configurable analyzer services in Settings
Add a Settings > Analyzer tab letting users CRUD custom MeshCore Analyzer
services with a star-toggle default and inline disabled switch. The chart
icon under each group-chat message now resolves at click time: built-in
Letsmesh when no enabled customs, the default when set, or a chooser
modal otherwise. Backend stops shipping the prebuilt analyzer_url and
emits packet_hash instead — the frontend substitutes {packetHash} in the
chosen URL template.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 15:34:45 +02:00
MarekWo e06e7b06dd feat(dm): apply a configured path to the device from Contact Info
Adds an upload-arrow button to every entry in the Paths list inside the
DM Contact Info modal: clicking it pushes that configured path to the
firmware as the active route, mirroring the console's change_path
command but without leaving the UI. After the device confirms, the
modal's device-path line refreshes so the new route is reflected
immediately.

Backend: POST /api/contacts/<pubkey>/paths/<id>/apply looks up the
configured path, runs dm.change_path() with its hex + hash_size, and
invalidates the contacts cache.
2026-06-05 13:43:19 +02:00
MarekWo 843d59a2d6 fix(channels): refresh per-send channel secret to keep echo correlation working
Channel indices on the device can shift after the user deletes a
channel — subsequent slots compact down by one — but mc-webui only
ran _load_channel_secrets() once at startup, so the in-memory cache
mapped channel_idx to whichever secret was there at boot. Once the
indices moved, expected_payloads for sent channel messages were
encrypted with the wrong key, so legitimate repeater echoes always
fell into the 'doesn't match expected candidates' branch and never
got linked to the originating send.

send_channel_message now calls _refresh_channel_secret(idx) before
building the candidate list: one extra get_channel(idx) round-trip
that fetches the current secret straight from the firmware, updates
the in-memory cache + DB if they had drifted, and is used for the
pkt_payload computation. If the slot is empty, the stale cache entry
is dropped.

Also bump the set_param timeout for path_hash_mode and custom_var to
20s — the meshcore lib has a 15s internal timeout, so the previous
5s outer wrapper raised a bare concurrent.futures.TimeoutError with
empty str(e) before the device's ERROR event could surface. The
exception handler now logs the exception type as well so future
empty-string errors are still diagnosable, and stores the
event.payload (not the never-defined event.data) when capturing the
sent message's pkt_payload field.
2026-06-05 10:42:07 +02:00
MarekWo c39037214c fix(messages): persist raw path_len byte so incoming path_hash_size is correct
meshcore lib 2.x splits the wire path_len byte into payload['path_len']
(masked hop count) + payload['path_hash_mode'] (hash-size mode). We were
storing only the masked half in channel_messages / direct_messages /
paths, so the downstream decode_path_len() in the API endpoints always
returned hash_size=1 — fine for the Hops counter but wrong for any UI
that renders the incoming hex path (e.g. echo-fallback rendering).

Added pack_path_len() that recombines the two fields back into the
firmware byte and routed all three insertion sites through it. The
channel-message socket emit now uses the recombined byte too, so
realtime path_hash_size matches the value the API will return on reload.

No schema migration needed — the column still holds an INTEGER. Old
rows continue to decode as hash_size=1 (their original behavior); only
newly received messages benefit from the fix.
2026-06-05 09:54:56 +02:00
MarekWo bcaa550809 fix(dm): persist delivery_path_hash_size so reloaded bubbles render multi-byte routes
Live dm_delivered_info already carried the correct hash_size, but the
DB row only kept delivery_path. After a reload the API filled in
path_hash_size from the incoming path_len column (NULL for outgoing
DMs → default 1), so 2-byte routes were re-rendered as single-byte
hops.

Added a delivery_path_hash_size column (auto-migrated, defaults to 1)
that update_dm_delivery_info now stores alongside the delivery path,
populated from the same hash_size already known by each delivery path
(retry ctx, PATH event, delayed contact backfill). /api/dm/messages
returns the new field; dm.js prefers it over path_hash_size when
rendering the Route line, falling back to the old field for legacy
rows.
2026-06-05 09:22:51 +02:00
MarekWo 60e7e57663 fix(ui): include out_path_hash_mode in cli.get_contacts_with_last_seen
This was the missing piece behind the lingering 1-byte path rendering
in Contact Info / Contact Management. The cache layer between
mc.contacts and the /api/contacts/detailed endpoint rebuilt each
contact dict by hand and dropped the out_path_hash_mode field, so
the API always saw the default 0 and rendered multi-byte paths as
single-byte hops.
2026-06-05 09:04:40 +02:00
MarekWo 4effa47fe1 fix(ui): multi-byte path rendering across contact list, DM modal, retry
Same root cause as the previous console fix: meshcore lib 2.x stores
out_path_len as the masked hop count and out_path_hash_mode separately.
Several UI surfaces and the DM retry logic were still decoding the
hash-size mode from the upper bits of out_path_len, which always yields
1 for in-memory contact data and silently truncates multi-byte paths.

Fixed sites:
- /api/contacts/detailed: path_or_mode and outgoing payload now use
  out_path_hash_mode; the field is included in /api/contacts too.
- dm.js: Contact Info modal computes hashSize for the import button
  from out_path_hash_mode.
- console "contacts" command: same correction as "path".
- device_manager._paths_match / _extract_path_hex: accept hash mode as
  a parameter; callers (_dm_retry_task, _delayed_path_backfill, Phase 2
  rotation dedup) pass contact.out_path_hash_mode.
- PATH event handlers: derive hash_size from path_hash_mode instead of
  decoding it from an already-masked path_len.
2026-06-05 08:54:29 +02:00
MarekWo fecf8cdccb fix(console): multi-byte hops in change_path parser and path display
The console treated 2-/3-byte hops as 1-byte:
- change_path "<name>" d103 5e34 (space-separated) was joined into
  continuous hex with hash_size=1, producing four 1-byte hops instead
  of two 2-byte ones.
- path <name> always rendered 1-byte hops because it decoded the
  hash-size mode from upper bits of out_path_len. In meshcore 2.x the
  library already masks out_path_len to the hop count and exposes the
  mode separately in out_path_hash_mode.

Parser now splits on commas, whitespace, or arrow separators and
requires consistent hop length. Display reads out_path_hash_mode and
also shows the byte size, e.g. "D103,5E34 (2 hops, 2B)".
2026-06-05 08:29:33 +02:00
MarekWo fd2b3d0f61 docs: cover sidebar sort/favorites, Quick Access placement, console transcript, sidebar breakpoint
User-guide: sidebar channels now sort by latest activity with favorites pinned first (star in
Manage Channels), Quick Access per-item placement + Hide switch in Appearance tab, new
Notifications settings tab, sidebar breakpoint in Interface tab, console change_path /
reset_path / 'direct' keyword, persistent transcript with jump-to-latest button.

Architecture: is_favorite in read_status, channels/favorites + console/output API endpoints.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 09:36:00 +02:00
MarekWo 8da9ee2455 fix(ui): sidebar breakpoint not applied in DM window
dm.html is a standalone template (not extending base.html), so it didn't
receive the .layout-wide class — the DM contact list never appeared as
a sidebar even on wide screens.

Added an inline script in dm.html <head> mirroring the base.html logic.
Also subscribes to the `storage` event so the iframe re-applies the
class live when the user changes the breakpoint in the parent window's
Settings -> Interface tab.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 21:48:51 +02:00
MarekWo 927fc518f0 feat(ui): user-configurable sidebar breakpoint width
The threshold above which the channel/DM list shows as a sidebar (vs.
collapsing to a top dropdown) is now user-configurable in
Settings -> Interface -> Layout. Persisted per device in LocalStorage
(key: mc-webui-sidebar-breakpoint, default: 992px, range: 600-2000).

Implementation: replaced hardcoded `@media (min-width: 992px)` with a
`.layout-wide` class on <html>, toggled by JS based on window.innerWidth
vs. the user's breakpoint. An inline script in <head> applies the class
synchronously to prevent layout flash on page load (same pattern as theme).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 21:35:56 +02:00
MarekWo cf5e95579c feat(console): accept 'direct' keyword in change_path
Setting a contact's path to Direct means 0 hops with an empty path body. The hex parser had no way to express that — empty/non-hex input always failed validation, and reset_path forces Flood instead.

Add a 'direct' keyword that bypasses hex parsing and sends an empty path with hash_size=1, producing out_path_len=0 (Direct). Update the usage block and the help entry to document it and to point at reset_path for the Flood case.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 21:04:59 +02:00
MarekWo 8f144c6e97 fix(console): jump to latest when modal opens
The console iframe lives inside a Bootstrap modal that is hidden on page load. While the modal is hidden the messages container has 0 height, so the scrollToBottom() that runs after loadOutputHistory() is a no-op. When the modal opens the container resizes to its real height but stays scrolled to the top.

Watch the container with a ResizeObserver and scroll to the bottom whenever its height transitions from 0 to non-zero, so the transcript opens at the latest entry on first show and on every reopen.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 07:55:23 +02:00
MarekWo a571d5388d fix(console): open at bottom, drop Disconnected from transcript, add jump-to-latest button
- Stop persisting "Disconnected" / "Failed to connect" — these are session-local events; saving them made every reopen begin with a stale red error.
- Scroll to the bottom after restoring transcript so reopens land at the latest entry instead of the top.
- Add a floating chat-style jump-to-latest button that appears whenever the user scrolls more than ~80px above the bottom and disappears once they're back at the latest entry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 07:42:51 +02:00
MarekWo 3ef1eac0be feat(console): rename to mc-webui, fix change_path, persist transcript
- Rename "meshcli Console" to "mc-webui Console" (modal title + docs).
- Drop redundant "Connected to..." messages; replace intro with a one-line "Type 'help' for available commands." hint.
- Use a teal device-name style so the header label is readable on the dark background.
- Display contact paths with commas (D1,90,05,54) instead of arrows in `contacts` and `path`, matching the standard MeshCore client.
- Fix `change_path`: previously read only args[2] after shlex split, silently writing a 1-byte path. Now joins remaining args, accepts comma/space/continuous-hex, validates hex, auto-deduces hash_size from comma-chunk length (1/2/3-byte hops), and routes through _change_path_async so path_hash_mode is set and the contacts cache is invalidated.
- Update `help` line and add a usage hint for the no-args form.
- Add capped persistent output transcript: GET/POST/DELETE /api/console/output (cap 500 entries). Console restores prior entries (faded) above a divider on open and exposes a trash button to clear it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 22:18:46 +02:00
MarekWo 58300f4a47 feat(ui): user-configurable Quick Access vs Main Menu placement
Lets users choose where each action appears: in the floating Quick
Access (FAB) bar or the slide-out Main Menu. Adds a "Hide Quick Access"
master switch and a per-item placement table in Settings -> Appearance.
Removes the stale "Refresh Messages" menu item (legacy of polling era,
WebSocket already covers it) and moves the Notifications toggle from
the menu to a dedicated Settings -> Notifications tab.

Each of the 11 configurable items is rendered in both locations;
applyItemPlacements() toggles d-none based on localStorage. Badges for
DM unread and pending contacts propagate to the Main Menu copies so
they stay in sync regardless of placement.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 21:37:55 +02:00
MarekWo a2d3111e1c feat(channels): sort sidebar by latest activity, with favorite tier
Channels in the sidebar and mobile dropdown now sort by most recent
message first, with favorited channels pinned above non-favorites.
Reordering is push-driven via the existing new_message socket event:
the affected item is moved to the top of its tier in the DOM, no full
re-render. Favorites are toggled via a star icon in Manage Channels
and persisted in read_status.is_favorite for cross-device sync.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 22:48:33 +02:00
MarekWo a335f521e4 docs: cover regions, searchable channel selector, path hash mode
User-guide: new Region Scopes section (registry CRUD, per-channel picker,
firmware v1.15 default), updated Switching Channels (searchable picker on
narrow screens, sidebar previews on wide), Settings Regions tab, path_hash_mode
in Device tab.

Architecture: regions/channel_scopes tables, /api/regions and /api/channels/scopes
endpoints, per-channel scope-key push under _send_lock in DeviceManager,
path_hash_mode field in /api/device/config, channel POST/join idempotency.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 07:45:55 +02:00
MarekWo 0d4e81d105 feat(regions): show clickable No region badge when channel has no scope
The status-bar pill used to disappear when the active channel had no
region scope, forcing users into Manage Channels to assign one. Now it
stays visible as a muted "No region" badge that opens the same Set
Region Scope picker when clicked.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 20:57:57 +02:00
MarekWo da82a46591 fix(regions): use explicit None entry to clear default in Region Registry
Replace the click-the-selected-radio-again gesture with a top-row
"None — use firmware default" radio, mirroring the per-channel region
picker. Users found the toggle gesture unintuitive; an explicit option
matches the picker pattern they already know.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 20:18:44 +02:00
MarekWo d858011228 fix(regions): reset backdrop inline z-index on hide so picker stays clickable
Bootstrap reuses the same .modal-backdrop element across show/hide cycles.
The previous stacked-modal fix bumped its z-index to 1065 inline but never
cleaned it up, so the next non-stacked open of the picker (e.g. via the
status-bar badge) reused that 1065 backdrop above the default-1055 modal,
covering the entire viewport with an unclickable overlay.

Capture the bumped backdrop reference in onShown and clear its inline
z-index in onHidden alongside the modal's.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 11:42:09 +02:00
MarekWo 4bf863ec27 fix(channels): show full year in channel-list date to avoid HH.MM lookalikes
Old DD.MM rendering (e.g. "20.04") was visually indistinguishable from a
time stamp; switch to DD.MM.YYYY for messages older than today.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 22:03:27 +02:00
MarekWo 05291e88fb fix(regions): dim Manage Channels modal when region picker opens on top
Bump the picker modal's z-index (1075) and its backdrop (1065) above the
underlying Manage Channels modal (1055) so the two layers visually
separate instead of blending together.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 21:55:14 +02:00
MarekWo d77d86087d fix(regions): allow clearing the default region in Region Registry
Click the already-selected radio to clear the default; new
DELETE /api/regions/default endpoint also pushes an empty CMD 63 to
the firmware so its persistent default is wiped too.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 21:39:17 +02:00
MarekWo e293de2a76 fix(regions): rename tab to Regions and soften v1.14 firmware error
Two small follow-ups after initial deployment.

- Rename the Settings tab 'Channels' -> 'Regions' (id now tabSettingsRegions).
  The tab manages the region registry, not channels; the old label was
  confusing. The per-channel picker still lives under Manage Channels as
  before.
- Graceful handling of firmware rejection: CMD_SET_DEFAULT_FLOOD_SCOPE
  (63) and CMD_GET_DEFAULT_FLOOD_SCOPE (64) were introduced in firmware
  v1.15.0; on v1.14.x the device replies with a generic ERR frame and
  our toast showed the unhelpful 'Firmware error: unknown'. Now the
  device_manager translates the empty/timeout reason into a concrete
  message naming the v1.15 requirement, and the api handler appends
  'Your choice is saved locally' so the user knows the local state
  still persists. Same treatment for the delete-default-region clear
  path.
2026-04-24 11:54:01 +02:00
MarekWo 226bd2abac feat(regions): status-bar indicator pill for active channel scope
Final slice — small but completes the feature.

- index.html: add #regionIndicator pill to the chat status bar, inline
  with the connection-status dot. Hidden by default; click opens the
  region picker for the current channel.
- app.js: loadChannelScopes() fetches /api/channels/scopes at page init
  (right after loadChannels). updateRegionIndicator() toggles the pill
  based on currentChannelIdx + window.channelScopes and is called on
  every successful loadMessages + after saveChannelScope.
- DM view deliberately untouched — region scope applies to flooded
  channel sends only, not DMs.
2026-04-24 07:29:12 +02:00
MarekWo afe0c7cf17 feat(regions): per-channel scope picker + send-flow integration
Fourth slice — the feature is now functional end-to-end from UI to radio.

- Manage Channels modal: each row now has a pin-map button between Mute
  and Share that opens a region picker for that channel; rows show an
  inline badge with the assigned region name.
- Region picker modal (new #regionPickerModal): radio list of regions
  with a "(None) — use firmware default" option at the top. Empty-state
  shows a "Manage Regions" CTA that deep-links to Settings > Channels.
- api.py: two new routes —
  - GET /api/channels/scopes          → bulk map for UI rendering
  - PUT /api/channels/<idx>/scope     → {region_id: int | null} set/clear
- device_manager.send_channel_message: looks up the channel's scope,
  then — under _send_lock — pushes the 16-byte key via CMD 54 before
  the actual send_chan_msg. Channels without a mapping get an all-zero
  key so a previously-set scope doesn't leak across channels (firmware's
  send_scope is sticky until overwritten, not one-shot).
2026-04-24 07:27:33 +02:00
MarekWo f04f0f1dd8 feat(regions): Settings > Channels tab with region registry CRUD
Third slice — users can now curate their device-wide region list. No
per-channel mapping yet; that's PR #4.

- base.html: new Channels tab in the Settings modal with an info banner
  pointing at regions.meshcore.nz, the list container, and an add-region
  form.
- app.js: loadRegions / addRegion / deleteRegion / setDefaultRegion
  mirroring the loadContactsSettings / saveContactsSetting pattern. Client
  -side name validation (isValidRegionName) mirrors the firmware
  RegionMap::is_name_char byte-rule exactly so users get instant feedback
  on invalid chars without a round-trip.
- api.py: four routes under /api/regions —
  - GET    /api/regions                       → list registry
  - POST   /api/regions   {name}              → derive key + insert; 409 dup
  - DELETE /api/regions/<id>                  → cascade channel mappings; if
      the deleted region was firmware default, best-effort clear on device
  - POST   /api/regions/<id>/default          → flip DB flag + push CMD 63;
      if firmware push fails, DB still flips and response includes a
      non-blocking `warning` for a toast
2026-04-24 07:24:15 +02:00
MarekWo 0e38e0ce8c feat(regions): DeviceManager wrappers for flood-scope commands
Second slice of the per-channel region-scope feature — firmware plumbing.
No UI, routes, or send-flow integration yet; those land in PR #3 / #4.

- _send_lock: threading.Lock added to __init__ (consumed in PR #4 to
  serialize the set-scope + send-channel-message pair across Flask
  threads; introduced here to keep the init diff small).
- set_flood_scope_key(key_hex): thin wrapper over the existing
  meshcore-py `set_flood_scope(bytes)` path (CMD 54). None/empty clears
  the volatile scope. Used on the channel-send hot path in PR #4.
- set_default_flood_scope(name, key_hex): hand-rolled CMD 63 frame
  (opcode + 31-byte NUL-padded name + 16-byte key = 48 bytes) via the
  lib's generic send() with [OK, ERROR] wait. Installed meshcore-py
  (<=2.2.15) has no wrapper for this opcode; frame format matches
  MyMesh.cpp lines 1893-1909.
- Deliberately NOT implementing CMD 64 (GET_DEFAULT_FLOOD_SCOPE): the
  library's reader drops RESP_CODE 28 as "unhandled" (reader.py:919-921),
  so there is no Event we can wait for. Until upstream adds support,
  mc-webui treats its own regions.is_default row as the source of truth
  and pushes one-way via CMD 63. Comment in code documents the reason.
2026-04-24 07:20:30 +02:00
MarekWo 8e353407d3 feat(regions): add data layer for per-channel region scopes
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.
2026-04-24 07:12:55 +02:00
MarekWo daf9c5c0db feat: show last message time and preview in channel list
Each channel item in the desktop sidebar and the mobile dropdown now
surfaces the timestamp of the last message (HH:MM today / DD.MM older)
and a truncated plain-text preview (up to 60 chars, mentions stripped).
Sidebar clamps preview to 2 lines, dropdown to 1 line. Empty channels
render as a single-line name, unchanged.

- api.py: /api/messages/updates returns last_message_preview +
  last_message_time; new _make_preview helper strips @[name] syntax
  and truncates with ellipsis.
- app.js: new channelLastMessages state populated by the poll loop and
  by the new_message socket event; populateChannelSidebar,
  renderChannelDropdownItems, and updateChannelSidebarBadges build and
  maintain the two-row layout (.channel-item-top + .channel-item-preview).
- style.css: sidebar and dropdown items switch to column flex; new
  .channel-item-top, .channel-last-time, .channel-item-preview rules.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 07:43:02 +02:00
MarekWo 57a0ca018d fix: treat slots with empty name as empty regardless of secret bytes
Some firmwares return SHA256(\"\")[:16] (e3b0c442...) for an empty
channel slot's secret instead of all zeros. The load path checked only
for the all-zero sentinel, so those slots passed the \"valid\" branch
and got persisted to the DB with a synthetic 'Channel N' name plus the
bogus secret. The stale rows then leaked into db.get_channels() and
would have supplied wrong keys for pkt_payload computation.

Anchor the decision on name presence: a slot is used iff firmware
returned a non-empty name. Drop the 'Channel {idx}' fallback so we
never invent names for empty slots. The existing end-of-loop cleanup
then removes any phantom rows already in the DB on next connect.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 20:52:35 +02:00
MarekWo cbcdbdcae9 fix: prevent duplicate channels from concurrent add/join requests
Two near-simultaneous POSTs to /api/channels/join (observed 7 ms apart
in demo-server logs) each found a different free slot and both
succeeded, producing two entries for the same channel name on the
device. This also shifted the sidebar so each channel rendered the
next one's messages.

- Wrap free-slot detection + set_channel in a module-level lock so
  concurrent requests serialize instead of racing.
- Idempotency: if a channel with this name already exists, return the
  existing slot with already_existed=true instead of creating a
  duplicate. Applies to both POST /api/channels and /api/channels/join
  (skipped when caller targets an explicit index).
- Disable submit buttons on create/join forms while a request is in
  flight, and guard against double-registration of the channel-link
  click delegate to stop a single click from firing N POSTs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 20:45:39 +02:00
MarekWo 66c378c17d fix: shrink navbar brand to 1rem on mobile so full 'mc-webui' fits
At 1.25rem the brand was still ~120px wide and got truncated to
'mc-we...' on a 360px-wide S20. Drop to 1rem so the whole name
fits without ellipsis while the navbar stays on one row.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 20:00:18 +02:00
MarekWo 10addba35f fix: force navbar to one row on mobile (flex-wrap: nowrap)
The actual root cause: Bootstrap's .navbar defaults to flex-wrap: wrap,
which lets the brand and the controls drop to separate rows when the
total just barely overflows. Adding flex-wrap: nowrap on .navbar and
its .container-fluid (which inherits flex-wrap) keeps everything on
one row. Brand also gets min-width: 0 + overflow: hidden +
text-overflow: ellipsis so it truncates gracefully if there's
genuinely no room (instead of forcing overflow).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 19:55:23 +02:00
MarekWo 14df7ead4d fix: shrink navbar brand on mobile so navbar controls fit one row
The selector was already narrow enough — the real culprit was the
.navbar-brand.h1 sitting at Bootstrap's default 2.5rem (40px), which
on a Samsung S20 ate ~200px just for 'mc-webui'. Cap brand at
1.25rem on screens ≤ 768px so the bell + selector + menu can sit
beside it on one row.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 19:50:12 +02:00
MarekWo 9ca055dd01 fix: drop input min-width override that pinned channel selector to 100px
The responsive @media (<768px) block had #channelSelectorInput pinned
at min-width: 100px !important, which pushed the wrapper's actual width
above the wrapper's own min-width: 80px. Remove the input min-width so
the wrapper's 80px takes effect; DM contact input keeps its 100px.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 19:44:48 +02:00
MarekWo b296913cd1 fix: shrink channel selector further to fit Samsung S20 navbar in one row
Reduce wrapper min-width 100px → 80px and tighten the form-select
chevron padding (2rem → 1.5rem right, 0.6rem → 0.5rem left) so the
navbar fits in one row on ~360px-wide phones while keeping the
chevron and text both visible.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 08:46:19 +02:00