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.
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.
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>
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>
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>
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>
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>
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>
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>
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>
Reduce wrapper min-width from 140px to 100px (with 140px max-width) so
the bell + channel selector + menu button all fit in one navbar row on
~412px-wide phones, without sacrificing readable channel names when
there's room to grow.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the native <select> channel picker (used on narrow screens) with
a custom searchable dropdown matching the DM contact picker UX: type to
filter, arrow/Enter keyboard nav, click-outside to close, per-channel
unread badges, muted styling. Wide-screen sidebar (lg+) is unchanged.
Also align the DM picker dropdown font with the wide-screen DM sidebar
(0.88rem / weight 400) — was inheriting larger/bolder from form-control.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
Introduces a dedicated conceptual walkthrough of device contacts, the
cache layer, the ignored/blocked flags, Settings → Contacts toggles,
recommended configuration, scenarios, recovery from the 350 limit,
auto-cleanup interaction, and FAQ/migration notes for users coming
from the official Android/iOS apps.
Also ships a standalone, responsive HTML version with embedded CSS
(light/dark auto) for sharing outside the repo, and links the guide
from user-guide.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
Catch up on ~36 commits since b60c99a. Document Device Settings tab
(public info + radio) with map picker and regional presets, quick-access
FAB cluster with drag/collapse/sizing, configurable route popup and toast
timeout/position, multi-arch Docker images (amd64, arm64, arm/v7), and
new endpoints /api/device/config and /api/ui/settings.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
hard_delete_contact() failed with FOREIGN KEY constraint when the
contact had a row in ignored_contacts or blocked_contacts, since those
FKs lacked ON DELETE CASCADE. Delete dependent rows first in the same
transaction; also update schema for new deployments.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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>
Pre-compute expected pkt_payloads at send time using channel secret +
timestamp (±3s for clock drift), then match echoes exactly instead of
only checking the 1-byte channel hash. Fixes race condition where an
incoming message's echo on the same channel could be incorrectly
attributed to a just-sent message (wrong Analyzer URL).
Falls back to channel-hash matching when channel secret is unavailable.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use GHA cache backend (cache-from/cache-to) so that unchanged
layers (especially the slow ARM pip install) are reused across runs.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
gcc, python3-dev, libjpeg-dev, zlib1g-dev are needed to compile
Pillow and pycryptodome from source on linux/arm/v7 (no pre-built
wheels available). Build deps are purged after pip install.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add QEMU and Buildx to CI workflow to build images for linux/amd64,
linux/arm64, and linux/arm/v7 platforms. Closes#29 discussion.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Right-align path popup and cap max-width to viewport to prevent
overflow on narrow screens (same approach as DM route popup fix).
Add tap-to-copy on route entries — copies path in comma-separated
format (e.g. 5E,32,0D,8C) to clipboard with visual feedback.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The old code in app.js used elapsed-time division to determine
"today" vs "yesterday", causing messages from late evening to
show as "today" when viewed shortly after midnight. Now both
app.js and dm.js compare calendar dates via toDateString().
Also adds "Yesterday" label support to dm.js.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Contact names stayed stale indefinitely because mc.contacts (in-memory
dict) was only populated at startup. When a remote node renamed itself,
the device firmware updated its contact list but the app never re-read it.
Now ensure_contacts(follow=True) is called when contacts_dirty is set:
- In _on_advertisement(): refresh before name lookup (incremental via lastmod)
- In get_contacts_with_last_seen(): refresh + DB sync before serving API data
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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>
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>
Event objects use 'payload', not 'data'. This bug was latent because
the cache was always populated during connect — only exposed after
the cache invalidation fix.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
get_device_info() cached SELF_INFO payload in _self_info and never
refreshed it after set operations, so get always returned stale values.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
get radio used wrong key names (freq/bw/sf/cr instead of
radio_freq/radio_bw/radio_sf/radio_cr from SELF_INFO payload).
set radio was missing entirely — would silently fall through to
custom variable handler. Now parses freq,bw,sf,cr and calls
mc.commands.set_radio().
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The set command was implemented but get was missing, causing
"Unknown param" error. Reads adv_loc_policy from device info.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Save collapsed/expanded state to localStorage (shared key for both
main chat and DM views) so buttons stay hidden when the user
previously collapsed them.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The DM iframe reloads on every modal open. During the show transition
the viewport is 0x0, causing clampFabPosition to push buttons to the
top-left corner. Now polls until viewport is valid before restoring.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add Settings quick-access button to both main chat and DM views
- Make FAB container draggable via toggle button with position saved to localStorage
- Add button size and spacing sliders in Settings > Appearance tab
- Use CSS custom properties for dynamic FAB sizing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Empty device channel slots have all-zero secrets (32 hex chars) which
passed the length check and got persisted to DB as "Channel N". This
caused ghost channels (e.g. Channel 14) to appear in unread counts
while the sidebar correctly showed only real channels.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
In-container BLE reconnection is unreliable because bleak leaves stale
GATT notification handles after abnormal disconnect, and adapter power-
cycling from within Docker corrupts bleak's internal BlueZ manager state.
New approach:
- On BLE disconnect or keepalive failure, immediately mark as permanently
failed (no in-container reconnect attempts)
- Health endpoint returns 503, Docker healthcheck triggers container restart
- Docker entrypoint script disconnects stale BLE connections before app
starts, ensuring clean GATT state for bleak
This is reliable because:
- MeshCore.create_ble(address=...) works on fresh container starts
- The BlueZ daemon on the host maintains adapter state correctly
- Container restart is fast (~5s) and gives a truly clean BLE state
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
bleak inside Docker cannot initiate new BLE connections — it can only
take over connections already established by BlueZ. Replace the
force-disconnect approach with a connect-via-BlueZ approach:
1. _ble_ensure_connected() connects the device via BlueZ D-Bus
(Device1.Connect) before bleak tries to take over
2. BleakScanner.find_device_by_address() provides the BLEDevice
object that bleak 3.x needs (raw MAC address doesn't work)
3. MeshCore.create_ble(device=...) takes over the BlueZ connection
On reconnect after disconnect:
1. Power-cycle adapter clears stale GATT notification handles
2. BlueZ re-connects the trusted device automatically
3. bleak takes over the re-established connection
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
In bleak 3.x, BleakClient(address_string) can't find paired BLE
devices that aren't actively advertising. This caused
BleakDeviceNotFoundError or 30-second connection timeouts.
Fix: pre-scan via BleakScanner.find_device_by_address() which queries
BlueZ's D-Bus object tree directly, then pass the BLEDevice object to
MeshCore.create_ble(device=...) instead of the raw MAC address.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
BlueZ auto-reconnects trusted BLE devices, which races with bleak's
connect and causes 'failed to discover services' or 'Notify acquired'.
Now we temporarily untrust the device before connecting (to prevent
BlueZ from auto-reconnecting during the handoff), then re-trust it
after bleak has established its GATT session.
Also adds _ble_retrust() helper to re-trust the device in a finally
block, ensuring the bond is maintained even on connection failure.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
On startup, _connect_with_retry also needs adapter power-cycling every
3rd failed attempt to clear stale GATT state from previous sessions.
Without this, the container can fail all 10 startup retries when BlueZ
holds stale notification handles.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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>
- Fix "repository name must be lowercase" error for GHCR tags
- Update actions/checkout to v5, docker/build-push-action to v6
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Push images to both Docker Hub and GitHub Container Registry
- Build on main (latest tag) and dev (dev tag) branches
- Update README with Docker Hub installation instructions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Automatically builds and pushes mc-webui image to Docker Hub
(mawoj/mc-webui) on push to main, with manual trigger support.
Tags: latest + version from git (e.g. 2026.04.01-b286072).
Based on x9daniel's PR #21, adapted for Docker Hub.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
After set_channel(), read back the actual secret from the device and
update both _channel_secrets in-memory cache and the DB. This fixes
newly-joined # channels (where firmware auto-generates the key) having
no repeater info, missing Analyzer URLs, and incorrect route data until
container restart.
Also clean up _channel_secrets on channel removal.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The setupAutoRefresh 60s interval was a legacy fallback from before WebSocket
support. All updates it handled are now covered by SocketIO events:
- new_message: channel messages and DMs
- echo: repeater/route metadata
- pending_contact: new handler added to update badge in real-time
checkForUpdates() is kept for initial load and manual refresh button only,
with its loadMessages() call removed (badges-only now).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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>
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>