148 Commits

Author SHA1 Message Date
MarekWo 4c6857a00f feat: numbered releases (2.1.0) with git tags and GitHub releases
The app already knew exactly which build was running - a calendar version
of commit date plus short hash - but nothing gave a release a name users
could quote, and the repo had no tags at all.

Adds a VERSION file as the single source of truth for a SemVer release
number, read by app/version.py alongside the existing build string rather
than replacing it: the number is for people, the build is for pinning down
a deploy, and both ship in /api/version and the template context. The menu
shows the release first with the build underneath. #versionText still holds
the build string, because the remote-update poller compares it to detect
that the server came back on a new build.

Resolution order is unchanged (frozen file > git > fallback), and a frozen
file written before this change still yields a correct release number, so
an already-deployed server does not need re-freezing to stay sane. The
container has no git, hence COPY VERSION into the image - otherwise a plain
'docker compose build' reports 0.0.0.

scripts/release.sh cuts a release from main: it refuses a dirty tree, a
wrong branch, a malformed number or an existing tag, extracts the notes
from the matching whatsnew section, then tags, pushes and publishes via gh.

Numbering starts at 2.1.0 rather than 1.x: the v2 line has been in
production since March and 'v1' is the archived pre-migration branch, so
1.x would have been ambiguous. Sections in whatsnew before 2.1.0 keep
their date-only headings - they were never tagged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:20:57 +02:00
MarekWo f54dc1d481 feat(repeaters): prefill saved password on login retry, map location picker, tidy list
- Login retry prompt now prefills the saved password (a wrong stored
  password and an unreachable repeater are indistinguishable, so a
  connection-caused failure no longer forces retyping). Adds
  GET /api/repeaters/<pk>/password for the trusted local UI.
- Repeater list: "last login" moves to its own line so a long path keeps
  the full row width on narrow phones.
- Settings -> Location: "Pick from map" button opens a Leaflet picker;
  clicking the map fills lat/lon and marks the section dirty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:27:13 +02:00
MarekWo 743969291e perf(api): batch echo enrichment in /api/messages
Replace per-message get_echoes_for_message() calls (N+1, up to 500
queries per request) with a single get_echoes_for_payloads() batch
query. With connection-per-call over a Windows bind mount each query
cost ~66 ms, making the endpoint take 33 s and time out in the UI;
now it completes in ~0.5 s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 22:37:29 +02:00
MarekWo 9668651a61 feat(pathanalyzer): Path Analyzer panel skeleton (stage 2)
Full-screen iframe panel wired per the add-fullscreen-panel checklist:
- /path-analyzer Flask view + standalone template (theme bootstrap before
  CSS, local vendor assets, Leaflet included for the stage-5 map view)
- path-analyzer.js: days selector (1/3/5/7), message table (time, channel,
  sender, preview, copyable packet hash, hops, echo count), spinner/empty
  states, local toast helpers fed from /api/ui/settings
- #pathAnalyzerModal in index.html, iframe src set on show.bs.modal for
  fresh data each open; FAB button + purple gradient class
- menu entry in #mainMenu (Tools) + placement radio row in Settings ->
  Appearance; registered in ITEM_PLACEMENT_DEFS/DEFAULTS (default: menu)
- style.css: #pathAnalyzerModal added to all three narrow-viewport
  fullscreen-override selector lists

Verified live via Playwright at 1280px and 390px, light + dark: modal
fills the viewport exactly, placement radios move the entry both ways,
iframe reloads on every open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 17:14:20 +02:00
MarekWo 8286fb1e65 feat(pathanalyzer): bulk messages API with batched echoes (stage 1)
- New GET /api/path-analyzer/messages?days=N endpoint: returns channel
  messages from ALL channels for the last N days (clamped 1..30) with
  echoes attached per message (path, snr, hash_size, direction,
  received_at), channel names and packet hashes included.
- New db.get_echoes_for_payloads(): chunked IN-clause batch fetch
  (<=500 params per query) instead of one query per message.
- Extracted _build_channel_secrets() and _get_row_pkt_payload() helpers
  from get_messages(); /api/messages behavior unchanged (verified live:
  field set identical, packet hashes and echo paths match the new
  endpoint for the same messages).
- Legacy rows with uncomputable pkt_payload degrade to packet_hash null
  + empty echoes instead of being dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 17:02:18 +02:00
MarekWo a036d6af6c feat(repeaters): Actions tool (stage 8)
- POST /api/repeaters/<pk>/action {action}: whitelisted one-shot commands
  via repeater_cmd_wait — zerohop_advert (advert.zerohop), flood_advert
  (advert), clock_sync (clock sync), reboot; admin-gated
- Replies surfaced verbatim with an ok flag (reply starts with OK), so
  firmware refusals like "ERR: clock cannot go backwards" show as-is
- reboot special case: the firmware restarts without ever replying, so a
  clean send followed by silence reports success ("Reboot command sent")
- Actions pane: action rows with inline result lines, flood advert
  styled as warning ("Not recommended - high network load"), Danger zone
  card with confirm()-guarded Reboot and a muted note that erase is only
  available on the USB serial console (firmware restriction)

Verified live against PL-KRA Wegrzce 2: zero-hop advert replied
"OK - zerohop advert sent" and the advert arrived back at our device
(log: Advert from 'PL-KRA Wegrzce 2' type=2); clock sync surfaced the
firmware refusal; reboot confirm-cancel path checked in Playwright
(reboot itself intentionally not executed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:22:48 +02:00
MarekWo 92f5a7edca feat(repeaters): Settings tool (stage 7)
- GET/POST /api/repeaters/<pk>/settings?section=X: sequential text-CLI
  get/set batches via repeater_cmd_wait; per-field values/errors on read,
  per-field ok|failed|reboot_required classification on write (reply
  starting ok/password now = ok, mention of reboot = reboot_required);
  admin-gated via new _require_repeater_admin helper (also reused by /cli)
- Settings pane: accordion with 8 sections (Basic, Radio, Location,
  Features, Network health, Advertisement, Operator info, Advanced),
  lazy-loaded on first expand (each field is one mesh round-trip),
  per-section Refresh/Apply, dirty tracking with header badges
- Field types: on/off switches, 0/1 switches, select (loop.detect),
  composite radio (freq,bw,sf,cr) with reboot warning + confirm dialog,
  write-only admin password (updates the saved password on success),
  owner.info textarea with pipe-to-newline mapping
- Failed writes keep the field dirty and surface the firmware reply
  (e.g. "Error: interval range is 60-240 minutes") under the input
- Strip "%" suffix when loading number fields (get dutycycle -> "70.0%")

Verified live against PL-KRA Wegrzce 2: all 8 sections read cleanly,
flood.max 64->63->64 write round-trip, firmware range rejection surfaced
per-field, radio confirm-cancel path, dutycycle % parsing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 14:43:16 +02:00
MarekWo 3e80ea0c85 feat(repeaters): CLI tool (stage 6)
Remote text console for the managed repeater. Core mechanism:
repeater_cmd_wait() sends the command and synchronously waits for the
reply — CLI replies arrive as CONTACT_MSG_RECV txt_type=1 with no
protocol-level correlation, so correlation = repeater lock (single
command in flight) + sender-prefix match. A single-slot waiter is
checked at the top of _on_dm_received: matched CLI replies are
consumed there and never stored as chat DMs; unmatched ones (e.g.
console fire-and-forget cmd) keep the legacy DM behavior. Wait time
derives from the device-suggested timeout (10-45 s clamp).

POST /api/repeaters/<pk>/cli is admin-gated (403 for guest sessions:
firmware silently drops guest text commands, which would look like a
timeout). Pane: dark terminal styled after the Console module, quick-
command chips, Enter to send, per-repeater arrow-key history in
localStorage, elapsed-time line, inline timeout errors (lost replies
happen over radio — a manual retry typically succeeds).

Settings (stage 7) and Actions (stage 8) will reuse repeater_cmd_wait.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 13:45:51 +02:00
MarekWo 2b6d82a11c feat(repeaters): Neighbors tool with map view (stage 5)
List view: all zero-hop neighbours (fetch_all_neighbours paginates the
firmware's ~14-entry pages), rows show resolved contact name (or
[pubkey prefix] for unknown repeaters), heard-ago and SNR; count in
the toolbar. Names/positions are enriched server-side by prefix-
matching device contacts with the DB contact cache as fallback.

Map view (List/Map toggle, shown only when something is mappable):
Leaflet with the managed repeater as a red marker, positioned
neighbours in green, dashed connection lines labeled with permanent
SNR tooltips, and a footnote counting neighbours without a known
position. Toggle hidden entirely when no coordinates exist.

Verified live on PL-KRA Wegrzce 2: 37/37 neighbours fetched, 20
mappable, SNR labels rendered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 13:23:43 +02:00
MarekWo 995cb49f4c feat(repeaters): Telemetry tool (stage 4)
All Cayenne LPP channels shown at once (no channel dropdown like the
standard app): one card per channel with typed rows — icon, name,
value and unit (voltage V, temperature °C, current A, power W,
humidity %, GPS lat/lon+alt, etc.). Channel 1 is labeled 'device'
(repeater's own vitals). Refresh + updated-ago label; timeout renders
inline with a retry button (exercised for real on a 2-hop repeater —
first attempt timed out, retry succeeded with 2 channels).

Backend: repeater_req_telemetry in device_manager (serialized under
the repeater lock; the old name-only request_telemetry stays for
sensor nodes) + login-gated GET /api/repeaters/<pk>/telemetry.
GPS values arrive as {latitude, longitude, altitude} objects from
meshcore 2.3.7 — formatter handles both object and array forms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 08:36:02 +02:00
MarekWo 707fd20426 feat(repeaters): Status tool (stage 3)
First management tool. The Status tile auto-fetches on open and shows
a compact three-section table (not MO's oversized tiles):
- System: battery %/V (linear 3.3-4.2 V estimate), uptime, repeater
  clock, queue length, debug/error events
- Radio: last RSSI/SNR, noise floor, TX/RX airtime
- Packets: sent + received (flood/direct split), duplicates, RX
  errors, channel utilization computed client-side as
  (tx_air+rx_air)/uptime (matches the firmware's own 10.09% reading)
Refresh button + "updated Ns ago"; errors render inline with retry.

Backend: GET /api/repeaters/<pk>/status and /clock, both gated on an
existing login session (401 need_login) to fail fast instead of a
2-min timeout. Clock loads as a follow-up request so the table
appears immediately, then the clock row fills in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 08:16:58 +02:00
MarekWo f288586ea6 feat(repeaters): Repeater Management panel (stage 2)
/repeaters/manage?pubkey=... — per-repeater management panel opened
automatically after login from the My Repeaters list:
- header card: name, shortened pubkey with copy, current path,
  location, ADMIN/GUEST badge from the captured login session
- tools grid (Status / Telemetry / Neighbors / CLI / Settings /
  Actions) with pane placeholders; CLI+Settings+Actions are locked
  for guest logins (firmware accepts text CLI from admins only)
- auto-login with the saved password when the in-memory session is
  gone (e.g. after app restart), password-modal fallback with retry
- REST: GET /api/repeaters/<pk> (merged entry + session state),
  GET .../session, POST .../logout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 07:24:52 +02:00
MarekWo ca2a6bacf9 feat(repeaters): My Repeaters panel (stage 1)
New full-screen panel (main menu / FAB) for repeater administration:
- repeaters table (saved per-pubkey admin password, login metadata);
  plaintext per observer_brokers precedent (single-user LAN app)
- REST /api/repeaters: list merged with device contact truth, add
  (device REP contacts only), set/clear password, remove, login
- device_manager: _repeater_lock serializes all repeater ops (companion
  firmware has a single pending-request slot); repeater_login now
  filters LOGIN_SUCCESS by pubkey_prefix and captures is_admin/
  permissions into an in-memory session store
- login timeout UX: wrong password and unreachable repeater are
  indistinguishable (firmware stays silent) - error message names both
- panel UI: add-picker, password modal (remembered password), per-
  repeater path editor reusing /api/contacts/<pk>/paths + Leaflet
  repeater map picker (adapted from the DM panel)
- meshcore pin bumped to >=2.3.7 (send_login_sync era API, fixed
  LOGIN_SUCCESS/LOGIN_FAILED parsing; container already ships 2.3.7)

Stage 2 (management panel with Status/Telemetry/Neighbors/CLI/
Settings/Actions tools) follows after user review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 22:55:44 +02:00
MarekWo a60ff138ff feat(observer): REST API for brokers, settings and status
/api/observer/settings (GET/POST, partial updates, IATA + advert
interval validation), /api/observer/brokers CRUD (passwords never
returned; omitted password stays, empty string clears) and
/api/observer/status. Every mutating endpoint hot-reloads the
ObserverManager so config changes apply without a restart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 19:03:32 +02:00
MarekWo debb711b71 feat(analyzer): seed Letsmesh as a normal DB row and flip switch UX
Letsmesh Analyzer is now a regular analyzers row, seeded once via an
app_settings flag on first startup. It can be renamed, disabled, or
deleted like any user entry. The frontend drops the special built-in
pseudo-row and the built-in slot in the chooser.

Click resolution updated: 0 enabled → toast "No analyzer configured";
1 enabled → open directly; multiple with a default → open default;
multiple without default → chooser.

The row switch and the edit modal now show "Enabled" (checked = active),
which matches intuition. The DB column remains is_disabled so no data
migration is needed — the UI simply inverts the value on read/write.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-01 21:56:55 +02:00
MarekWo e14c4fab06 fix(channels): include message id in /api/messages response
/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>
2026-06-09 19:06:08 +02:00
MarekWo 7bdccac60a chore(status): expose path_hash_mode/size for debugging resend snapshots
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>
2026-06-09 14:48:20 +02:00
MarekWo 4729055900 feat(channels): firmware version gate for raw resend (requires ≥1.16)
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>
2026-06-09 13:03:48 +02:00
MarekWo 67c59cc341 feat(channels): backend resend endpoint via CMD_SEND_RAW_PACKET
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>
2026-06-09 12:39:34 +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 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 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 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 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 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 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 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 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 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 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 3b4ed26c50 feat: path_hash_mode selector in Settings + global Close button
Adds a Path hash mode dropdown (1B/2B/3B) to Settings → Device → Public
Info, so the mode can be switched from the UI instead of the meshcli
console. The Settings modal now has a persistent Close button in the
footer, visible on every tab.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 07:32:50 +02:00
MarekWo 3dd1c52687 feat: contacts settings tab with suppress + auto-ignore options
Move Manual approval toggle into a new Contacts tab in the global
Settings modal and clean up the Contact Management panel (drop the
duplicated Settings/Manage Contacts headers, shorten the Existing
Contacts blurb). Add two new persisted options gated on Manual
approval being ON: Suppress new advert notifications (frontend hides
FAB badge + browser notification while the Pending list itself stays
populated) and Automatically add new contacts to "Ignored" (advert
handler marks the new contact ignored before emitting pending_contact,
so the user is silenced end-to-end while contacts remain in the cache
for promotion via "To Device").

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 10:01:58 +02:00
MarekWo bd0a6b492e feat: configurable route popup + toast display time and position
Users complained that the route popup under group-chat messages and the
top-of-page notification toasts auto-close before they can read them, and
some users wanted to move the toasts out of the top-left corner.

Adds to Settings modal:
- Group Chat tab: route popup auto-close timeout + "don't close" switch
  (applies to both channel popups and DM route popups)
- New Interface tab: toast auto-close timeout, "don't close" switch, and
  five position options (top-left/top-right/bottom-left/bottom-right/center)

Persisted as chat_settings (extended) and a new ui_settings row in the
app_settings table, with /api/chat/settings and /api/ui/settings endpoints.
Default toast delay bumped from 1.5s to 2s.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 08:12:51 +02:00
MarekWo bbfca38d34 fix: use adv_lat/adv_lon keys for device coordinates
Device info from meshcore uses adv_lat/adv_lon, not lat/lon.
Fixed in get_param, set_param (lat/lon individually), and the new
/api/device/config endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 19:26:42 +02:00
MarekWo 58d7d9af18 feat: add Device settings tab with Public Info and Radio Settings sub-tabs
Add a Device tab as the first tab in the Settings modal with two sub-tabs:
- Public Info: device name, coordinates with map picker, advert location sharing
- Radio Settings: frequency, bandwidth, SF, CR, TX power with region presets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 17:08:57 +02:00
MarekWo f352ccd968 fix(ble): add keepalive and robust reconnection for BLE zombie connections
BLE connections can enter a "zombie" state where notifications (reads) still
arrive but writes silently fail.  This went undetected until the user tried
to send a message, at which point the connection was already dead.

Additionally, after an abnormal BLE disconnect, BlueZ retains stale GATT
notification handles, causing reconnection to fail with
"[org.bluez.Error.NotPermitted] Notify acquired".

Changes:
- Add BLE keepalive loop (60s interval) that sends get_bat() to detect
  zombie connections proactively and trigger reconnection automatically
- Add adapter power-cycle (hci0 off/on via D-Bus) during BLE reconnection
  to clear stale GATT notification state
- Dedicated _ble_reconnect() with 5 attempts + adapter reset between each
- Health endpoint returns 503 when BLE permanently fails, triggering
  Docker container restart via healthcheck
- Guard against concurrent reconnection attempts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 13:37:33 +02:00
MarekWo 29e5e6982d fix(chat): prevent poll-triggered reload after send by using server timestamp
The 60s checkForUpdates poll was detecting has_updates due to clock skew
between client and server timestamps. Now the send API returns the server
timestamp, and the frontend uses it for markChannelAsRead — ensuring the
poll sees no updates for own sent messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 10:28:54 +02:00
MarekWo 6eb2250d88 fix(chat): remove separator line in bubbles and use WebSocket for echo updates
Remove unnecessary border-top separator above action buttons in message bubbles.
Replace 15s deferred loadMessages() after send with real-time echo updates via
WebSocket — API now returns msg_id so optimistic message gets linked to DB record.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 10:17:10 +02:00
MarekWo e8f271f4ef feat(path_hash_mode): add hop_count and path_hash_size to API responses
Stage 2 of path_hash_mode support. All API endpoints and SocketIO
emissions now include decoded hop_count and path_hash_size fields
alongside the raw path_len, so the frontend can display and segment
paths correctly for any hash mode.

Changes:
- Import decode_path_len in api.py
- GET /api/messages: add hop_count, path_hash_size, echo_hash_sizes
- GET /api/messages/<id>/meta: add hop_count, path_hash_size, echo_hash_sizes
- GET /api/dm/messages: add hop_count, path_hash_size
- SocketIO new_message emission: add hop_count, path_hash_size

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 10:00:03 +02:00
MarekWo 1d9742a1ee style(contacts): change existing contacts badge to show total + device count
Format changed from "X/350 (Y cached)" to "Y (🖥 X/350)" where Y is
total known contacts and X is device count, with bi-cpu icon for device.
Applied consistently to both the manage tile and existing contacts header.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 08:35:00 +02:00
MarekWo 147a12c8f5 fix(dm): persist delivery_status='delivered' on ACK receipt
DM delivery status was lost when switching conversations because
_confirm_delivery() only stored the ACK record and emitted a socket
event, but never set delivery_status='delivered' in direct_messages.

During retries, each attempt generates a new ACK code. The DM record
stores the initial expected_ack, but the actual ACK may arrive for a
later retry's code. The ACK lookup by expected_ack then fails to match.

Now _confirm_delivery() also sets delivery_status='delivered', and
message loading checks this DB field first (like it already did for
'failed'), so delivery persists across page navigations.

Also fixed 213 existing DMs on server via data migration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 14:49:37 +02:00
MarekWo 710f69c350 feat: add BLE transport support for companion devices
Integrate meshcore library's BLE connection (via bleak) as a third
transport option alongside serial and TCP. Priority: BLE > TCP > Serial.

Config: MC_BLE_ADDRESS and MC_BLE_PIN environment variables.
Docker: bluez/dbus packages, NET_ADMIN cap, D-Bus socket mount.
UI: transport type badge in navbar, transport_type in /api/status.
Watchdog: skip USB reset for BLE connections (same as TCP).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 10:03:45 +02:00
MarekWo 7dbbba57b9 feat(dm): add real-time retry status and persistent delivery info
Show retry progress in DM message bubble via WebSocket:
- "attempt X/Y" counter updates in real-time during retries
- Failed icon (✗) when all retries exhausted
- Delivery info persisted in DB (attempt number, path used)

Backend: emit dm_retry_status/dm_retry_failed socket events,
store delivery_attempt/delivery_path in direct_messages table.
Frontend: socket listeners update status icon and counter,
delivered tooltip shows attempt info and path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 12:25:35 +01:00