The MemoryLogHandler broadcasts every record to the /logs namespace, but
with async_mode='threading' Socket.IO falls back to long-polling. Each
polling request is logged by werkzeug, the broadcast wakes the pending
poll, the client immediately re-polls, and the cycle repeats — producing
10+ requests/sec per open System Log tab. Filter werkzeug access logs
for /socket.io/ and /api/logs/ paths so neither the buffer nor the
broadcast trigger the loop.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CMD_SEND_RAW_PACKET bypasses sendFlood()'s _tables->hasSeen() self-mark.
The firmware seen-table is 160 entries in RAM and rolls over in minutes
on a busy mesh, so a resend issued after the original hash got evicted
comes back via repeater echo, fails hasSeen (entry gone), and is
delivered as RESP_CODE_CHANNEL_MSG_RECV. Result: the user's own resend
appears as an "incoming" message from themselves a few minutes later.
Detection: in _on_channel_message compute the expected pkt_payload from
the event's (channel_secret, sender_timestamp, txt_type, raw_text) and
ask the DB if any own row already has that exact hash. If yes, treat as
self-echo and return — no DB insert, no SocketIO emit. Index idx_cm_pkt
keeps the lookup cheap. Guarded with try/except so any detection failure
falls through to normal handling — we never want a check bug to drop
real inbound messages.
Documented as a known limitation in reference_meshcore_raw_packet.md;
this lifts that caveat.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
/api/messages was dropping channel_messages.id when building the response
dict. The frontend therefore had msg.id=undefined for history-loaded rows,
so createMessageElement's "typeof msg.id === 'number'" guard suppressed
the raw-resend button, and refreshMessagesMeta couldn't find the wrappers
either (data-msg-id only gets set when msg.id is truthy). End result: the
raw-resend button only appeared on messages sent in the current session
and was lost on every channel switch or page reload.
Surfacing id costs nothing — the field already exists on the row — and
also unblocks any future per-message UI that needs to address the row
directly. createMessageElement already handles undefined id correctly for
older clients, so this is a one-way frontend benefit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
loadStatus and loadMessages fire in parallel at page init. Whichever lost
the race left own-message bubbles without the raw-resend button — and once
the row was rendered, neither displayMessages (no re-render on switch
without going back through createMessageElement, which is gated on
window.deviceCaps at the time of call) nor refreshMessagesMeta (skips rows
that already have route info) would patch it back in. So the button only
ever appeared for messages sent in the current session.
Walk visible own bubbles in two places — at the end of loadStatus and at
the end of displayMessages — and inject the button on any row missing it.
Idempotent (skips bubbles that already have .btn-raw-resend), cheap (no
network), and covers both the page-reload race and the channel-switch /
archive-view paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The optimistic-send path renders the bubble with msg.id = "_pending_<ts>"
before the API confirms. The previous PR baked that string straight into
onclick="resendChannelMessageRaw(_pending_<ts>, this)" — an undefined
identifier — so the first click after sending threw ReferenceError and
nothing happened.
Skip the raw-resend button entirely while msg.id is non-numeric, then run
a one-shot refreshMessagesMeta([real_id]) right after the optimistic id
swap so the button shows up immediately even on channels where no echoes
arrive (so the existing echo-driven inject path never fires).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PR #5 of 5. Wires the user-facing controls for the raw-resend feature.
Channel messages (own):
- The existing arrow-repeat button only pasted content into the composer
for hand-edits, which the "Resend" tooltip mis-named as a true resend.
Rename it to "Edit message" with a pencil-square icon.
- Add a new arrow-repeat button that POSTs to /api/messages/<id>/resend.
Tooltip explains the actual semantics ("rebroadcast same packet so
unreached repeaters can pick it up"). Spins .btn icon while in flight,
shows a toast on result. Rendered only when the cached
/api/status.supports_raw_resend is true (firmware ≥1.16).
- Inject the same buttons in updateMessageMetaDOM so history items loaded
before window.deviceCaps was populated still get the new button on the
next echo-driven meta refresh.
DM messages: rename the equivalent paste-button to "Edit message" with
the same pencil icon for UI consistency. The protocol-level retry stays
unchanged — there's no per-DM raw resend button (auto-retry covers it).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Earlier path_hash_mode fix updated the send-time build but the matching
edit to _refresh_raw_packet_if_drifted didn't make it into commit 10df846.
For channels where the secret isn't available at send time, guess_pkt_payload
stays None and raw_packet is created for the first time in this fallback
path (triggered when echo correlation matches via the channel-hash branch).
Without the path_hash_size argument the build defaulted to 1-byte hashes,
producing the same mixed-size badge the prior fix was meant to eliminate.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A test resend was producing 1-byte hash entries despite the device being
configured with path_hash_mode=1 (2-byte). Surfacing the cached values
in /api/status makes it possible to verify from the client whether the
device_info fetch at connect actually populated the cache, without
shelling into the container.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Resends were building raw_packet with the default 1-byte path-hash size,
ignoring the device's actual path_hash_mode. When path_hash_mode=1 (2-byte
hashes) the original send produced 2-byte path entries in repeater echoes,
but the resend's path_len byte said "1-byte" — so post-resend echoes
appended 1-byte hashes, mixing into the badge as inconsistent tokens
(e.g. "44D8, D103, E7" — the trailing E7 was a 1-byte fragment).
Cache path_hash_mode from DEVICE_INFO at connect (fw_ver_code >= 10) and
expose path_hash_size = max(1, mode+1). Pass it through to
_build_grp_txt_raw_packet in send_channel_message and the clock-drift
refresh path. Keep cache in sync with set_param('path_hash_mode', N).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PR #4 of 5. After a successful resend, re-arm _pending_echo with the
original msg_id and known pkt_payload so echoes from previously-unreached
repeaters that pick up the rebroadcast are classified as 'sent' and carry
msg_id in the SocketIO emit.
The frontend echo handler now collects forced msg_ids and passes them to
refreshMessagesMeta(forceIds), which bypasses the "already has route info,
skip" guard for those ids. End result: clicking resend extends the
repeater list on the existing message's badge in place — no duplicate row,
no stale count.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CMD_SEND_RAW_PACKET (0x41) was introduced in companion-v1.16.0
(FIRMWARE_VER_CODE bump 11 → 13). Older firmware returns
ERR_CODE_UNSUPPORTED_CMD with no useful context for the user.
Capture fw_ver_code from the DEVICE_INFO event at connect (re-using the
existing send_device_query call) and expose a supports_raw_resend
property. The resend endpoint now refuses early with a clear message
("Firmware too old for raw resend, need ≥1.16, device reports
fw_ver_code=N") and /api/status surfaces both fw_ver_code and the
supports_raw_resend flag so the UI can hide or disable the button on
older firmware.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The lib's reader.py wraps device ERROR frames as {error_code, code_string},
not {reason, error}. The previous extraction collapsed every device error to
"unknown error", hiding the actual ERR_CODE_* the firmware sent back. Check
code_string/reason/error in order, then fall back to a raw error_code, then
"unknown error".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PR #3 of 5. Adds POST /api/messages/<msg_id>/resend, which re-broadcasts an
own channel message verbatim using the raw_packet bytes captured at send
time. Pushes the wire bytes directly through companion command 0x41
(CMD_SEND_RAW_PACKET), bypassing the higher-level send paths so repeaters
dedupe by packet hash via Mesh::hasSeen — only previously-unreached nodes
will pick up the resend.
Returns 404 for unknown msg_id, 400 for not-own / missing snapshot /
disconnected device, 500 for unexpected device errors.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PR #2 of 5. Builds the full GRP_TXT wire bytes (header + transport_codes if
scoped + path_len + encrypted payload) from the ts+0 pkt_payload guess and
stores it in channel_messages.raw_packet right after the send. When echo
correlation later identifies the actual pkt_payload (potentially using a
different ±dt candidate due to host/firmware clock drift), the raw_packet is
rebuilt from the actual one so a future resend matches the original packet
hash and dedupes at the repeaters.
Transport-scope codes are computed in Python via HMAC-SHA256(scope_key,
payload_type||payload)[:2], mirroring TransportKey::calcTransportCode in
MeshCore Core (including the 0x0000/0xFFFF reservations).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Stores the full hex packet snapshot (header+transport_codes+path_len+payload)
captured at send time, enabling future "Resend (same packet hash)" feature
that lets repeaters deduplicate via Packet::calculatePacketHash while still
forwarding to nodes that didn't hear the original.
NULL for received and pre-migration rows (resend button stays disabled there).
PR #1 of 5; subsequent PRs wire up send-time capture, backend endpoint, echo
list merge, and UI.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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.
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.
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.
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.
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.
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)".
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>
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>
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>
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>
- 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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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.
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).