115 Commits

Author SHA1 Message Date
MarekWo a2d3111e1c feat(channels): sort sidebar by latest activity, with favorite tier
Channels in the sidebar and mobile dropdown now sort by most recent
message first, with favorited channels pinned above non-favorites.
Reordering is push-driven via the existing new_message socket event:
the affected item is moved to the top of its tier in the DOM, no full
re-render. Favorites are toggled via a star icon in Manage Channels
and persisted in read_status.is_favorite for cross-device sync.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

- _send_lock: threading.Lock added to __init__ (consumed in PR #4 to
  serialize the set-scope + send-channel-message pair across Flask
  threads; introduced here to keep the init diff small).
- set_flood_scope_key(key_hex): thin wrapper over the existing
  meshcore-py `set_flood_scope(bytes)` path (CMD 54). None/empty clears
  the volatile scope. Used on the channel-send hot path in PR #4.
- set_default_flood_scope(name, key_hex): hand-rolled CMD 63 frame
  (opcode + 31-byte NUL-padded name + 16-byte key = 48 bytes) via the
  lib's generic send() with [OK, ERROR] wait. Installed meshcore-py
  (<=2.2.15) has no wrapper for this opcode; frame format matches
  MyMesh.cpp lines 1893-1909.
- Deliberately NOT implementing CMD 64 (GET_DEFAULT_FLOOD_SCOPE): the
  library's reader drops RESP_CODE 28 as "unhandled" (reader.py:919-921),
  so there is no Event we can wait for. Until upstream adds support,
  mc-webui treats its own regions.is_default row as the source of truth
  and pushes one-way via CMD 63. Comment in code documents the reason.
2026-04-24 07:20:30 +02:00
MarekWo 8e353407d3 feat(regions): add data layer for per-channel region scopes
Introduces the SQLite-backed region registry and channel->region mapping
that will drive the per-channel flood-scope feature. No UI or device
wiring yet; those land in subsequent PRs.

- schema.sql: new `regions` and `channel_scopes` tables + partial index
  on the default flag.
- database.py: CRUD helpers for regions (create/list/get/delete/default)
  and channel_scopes (set/get/bulk-load) with ON DELETE CASCADE.
- app/meshcore/regions.py: pure helpers for SHA256('#'+name)[:16] key
  derivation and firmware-compatible name validation (mirrors the
  `RegionMap::is_name_char` rule `c in {-,$,#} or c>='0' or c>='A'`).
- tests/test_regions.py: known SHA256 vectors, validator coverage
  (incl. the firmware quirk that `_` and other 0x5B-0x60 chars are
  admitted), and CRUD + cascade integration tests.
2026-04-24 07:12:55 +02:00
MarekWo daf9c5c0db feat: show last message time and preview in channel list
Each channel item in the desktop sidebar and the mobile dropdown now
surfaces the timestamp of the last message (HH:MM today / DD.MM older)
and a truncated plain-text preview (up to 60 chars, mentions stripped).
Sidebar clamps preview to 2 lines, dropdown to 1 line. Empty channels
render as a single-line name, unchanged.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 08:46:19 +02:00
MarekWo 8194158241 fix: narrow channel selector to keep navbar on one line on small screens
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>
2026-04-22 08:42:16 +02:00
MarekWo 309efe0ce5 feat: searchable channel picker on narrow screens + DM picker font fix
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>
2026-04-22 08:32:03 +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 cfdb68390f docs: add Contact Management guide (markdown + HTML)
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>
2026-04-18 11:21:39 +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 0a08759065 docs: sync README, user guide, and architecture with recent features
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>
2026-04-15 16:14:57 +02:00
MarekWo 8ccb3100c2 fix: cascade-clean ignored/blocked rows on hard contact delete
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>
2026-04-15 08:35:31 +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 77c3ffa5c2 fix: prevent echo mis-correlation for sent channel messages
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>
2026-04-14 21:47:07 +02:00
MarekWo 10cc2031d1 feat: add GitHub Actions cache for Docker layer builds
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>
2026-04-12 21:30:05 +02:00
MarekWo 74e4327a37 fix: add build deps for Pillow/pycryptodome on ARM
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>
2026-04-12 21:15:31 +02:00
MarekWo d04fd817f9 feat: add multi-arch Docker builds for Raspberry Pi support
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>
2026-04-12 21:06:27 +02:00
MarekWo f7f5beb8b8 fix: route popup positioning on small screens + tap-to-copy
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>
2026-04-12 20:53:54 +02:00
MarekWo acec9e92cf fix: use calendar date comparison for message timestamps
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>
2026-04-12 13:19:10 +02:00
MarekWo 8f8bd30747 fix: refresh mc.contacts from device on dirty flag to update stale names
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>
2026-04-09 12:29:45 +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 bc1da9e45e fix: get_device_info checked for 'data' attr instead of 'payload'
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>
2026-04-06 14:43:26 +02:00
MarekWo 1e6f8caf03 fix: invalidate self_info cache after set_param
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>
2026-04-06 14:40:14 +02:00
MarekWo c3f61ce3f7 fix: get radio returns actual values, implement set radio command
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>
2026-04-06 14:33:13 +02:00
MarekWo 3086ac1f8e fix: add value hints to console help for telemetry, advert and path params
Unified format: <0-2> on the left, (0=x/1=y/2=z) on the right.
Values sourced from MeshCore firmware defines:
- telemetry_mode_*: 0=off, 1=selected, 2=all
- advert_loc_policy: 0=none, 1=share, 2=prefs
- path_hash_mode: 0, 1, 2

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 14:24:25 +02:00
MarekWo 1a194d5050 fix: implement get advert_loc_policy console command
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>
2026-04-06 14:10:20 +02:00
MarekWo 19b2a172c8 feat: persist FAB collapsed state across page loads
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>
2026-04-06 12:26:56 +02:00
MarekWo fb99054e4b fix: defer FAB position restore when iframe viewport is too small
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>
2026-04-06 12:19:44 +02:00
MarekWo 60c698deb2 feat: add Settings FAB button, drag-and-drop positioning, and size/spacing controls
- 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>
2026-04-06 11:59:24 +02:00
MarekWo 6c02220719 fix: skip empty channel slots during sync, clean up stale DB channels
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>
2026-04-06 11:08:38 +02:00
MarekWo c36d7b5fbf fix(ble): simplify reconnection — rely on container restart for clean state
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>
2026-04-05 16:39:03 +02:00
MarekWo 53063f199a fix(ble): connect via BlueZ D-Bus instead of bleak direct connect
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>
2026-04-05 14:13:06 +02:00
MarekWo 9c692fac8b fix(ble): use BleakScanner to find device before connecting
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>
2026-04-05 14:10:47 +02:00
MarekWo a92b505975 fix(ble): untrust device during connect to prevent BlueZ auto-reconnect
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>
2026-04-05 14:06:18 +02:00
MarekWo 1de98433d4 fix(ble): add adapter power-cycle to startup retry loop
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>
2026-04-05 13:40:53 +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 61d60ea4b6 fix(ci): lowercase GHCR owner name and update actions versions
- 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>
2026-04-01 15:39:30 +02:00
MarekWo 8aac3d4839 ci: add dual registry (Docker Hub + GHCR) and dev branch builds
- 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>
2026-04-01 15:35:33 +02:00
MarekWo bc578c7018 ci: add Docker Hub publish workflow
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>
2026-04-01 15:00:32 +02:00
MarekWo b2860720d5 fix(ui): change initial DM status text to 'Sending...'
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 07:54:07 +02:00
MarekWo f6c9c65a51 fix(channels): refresh channel secret cache after join/create
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>
2026-03-31 21:00:34 +02:00
MarekWo 5919e43f3a refactor(chat): remove 60s polling, rely fully on WebSocket for real-time updates
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>
2026-03-31 10:32:24 +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 695321c0c9 fix(dm): show delivery info immediately on ACK/failure without reopen
_confirm_delivery() now saves retry context (attempt, max_attempts,
path) and emits dm_delivered_info so the frontend shows delivery
details instantly. Similarly, dm_retry_failed now includes attempt
count so the failure state shows how many attempts were made.

Previously this info was only available after reloading messages
from DB (closing and reopening the conversation).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 09:58:41 +02:00
MarekWo 0ecb91aa08 fix(dm): show 'Attempt 1/...' immediately after sending DM
The retry progress element was always created empty. The socket event
with the real attempt count could arrive before the DOM was ready,
causing it to be lost. Now pending messages pre-populate with
'Attempt 1/...' which gets updated to the real count when the socket
event arrives.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 09:47:41 +02:00
MarekWo b60c99aad1 docs: update documentation for BLE transport, DM retry scenarios, and path_hash_mode
Add new docs to README table (DM retry logic, BLE pairing guide), update
architecture diagram and DB schema for BLE/delivery tracking, rewrite DM
retry settings section in user guide to reflect 4-scenario matrix, add BLE
troubleshooting reference, and update .claude/context files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 19:50:43 +02:00
MarekWo a7c5e1a8c3 fix(path_hash_mode): echo badge uses echo hash_size, not message path_hash_size
For own (sent) messages, path_len is NULL so path_hash_size defaults to
1, causing echo badge to show 1-byte prefixes (D1, 5E) instead of
2-byte (D103, 5E34) when path_hash_mode>0. Now uses hash_size from the
first echo record (echo_hash_sizes[0]) which carries the correct value
from RX_LOG_DATA parsing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 15:22:34 +02:00
MarekWo 2368ec656e feat(path_hash_mode): fix DM route display and delivery path segmentation
Stage 4 of path_hash_mode support. DM delivery paths now carry hash_size
through the entire pipeline: retry context → ACK handler → SocketIO
emission → frontend rendering. All hardcoded 2-char hex segmentation
removed from dm.js.

Backend changes (device_manager.py):
- Track path_hash_size alongside path_desc in DM retry context
- Update path_hash_size on path rotation and flood fallback
- Add hash_size to all 4 dm_delivered_info SocketIO emissions
- Derive hash_size from PATH event path_len for discovered paths

Frontend changes (dm.js):
- Add segmentHexPath() utility (shared by all 3 route functions)
- formatDmRoute(), buildDmRouteHtml(), showDmRoutePopup() accept hashSize
- All call sites pass hash_size from event data or message context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 13:11:00 +02:00
MarekWo 083c322741 feat(path_hash_mode): fix frontend hops display, path segmentation, echo badge
Stage 3 of path_hash_mode support. All hardcoded 1-byte hash assumptions
removed from app.js — hops, path segments, and echo badges now use the
decoded hop_count and hash_size from the backend.

Changes in app.js:
- appendMessageFromSocket: pass hop_count, path_hash_size, echo_hash_sizes
- updateMessageMetaDOM: use hop_count instead of raw path_len for Hops
- updateMessageMetaDOM: segment paths by hash_size*2 chars, not fixed 2
- updateMessageMetaDOM: echo badge uses hash_size-aware prefix length
- createMessageElement: same fixes as updateMessageMetaDOM
- showPathsPopup: segment paths by hash_size, derive hops from segments

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 10:22:57 +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 719e11e868 feat(path_hash_mode): add decode_path_len and fix RX_LOG_DATA parsing
Stage 1 of path_hash_mode support. The critical bug in _on_rx_log_data
treated the raw path_len byte as a direct byte count, which breaks with
mode>0 (e.g. mode=1, 0 hops → path_len=0x40=64, reading 64 bytes of
non-existent path data). Now properly decodes the encoded path_len byte
into hop_count, hash_size, and path_byte_len.

Changes:
- Add decode_path_len() utility for MeshCore v1.14+ path_len encoding
- Fix _on_rx_log_data binary parsing to use decoded path length
- Pass hash_size through _process_echo → DB insert → SocketIO emission
- Add hash_size column to echoes table (schema + migration)
- Update insert_echo() to store hash_size (default 1 for backward compat)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 09:47:20 +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 a983210e10 style(dm): move timestamp above bubble, improve meta readability
Move DM timestamp+status row above the message bubble (consistent with
group messages). Increase delivery/SNR meta font size and adjust color
for better readability in both light and dark themes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 20:32:36 +02:00
MarekWo 10c232fc7d fix(ble): force-disconnect stale BlueZ connection before connecting
BlueZ auto-reconnects trusted BLE devices after container restart,
blocking bleak from establishing a new GATT session. Clear the stale
connection via D-Bus before each connect attempt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 19:42:34 +02:00
MarekWo 9f335794e4 fix(ble): update runtime device name on every connect
BLE connections with retries can take >60s, exceeding the startup
wait timeout. Move runtime_config.set_device_name() into _connect()
so the navbar shows the correct name regardless of connection delay.
Also fixes name update on reconnections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 19:24:45 +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 1fdc2eda93 docs(ble): add troubleshooting section to pairing guide
bluetoothctl info auto-connects to trusted devices, stealing the
connection from Docker — document hcitool as safe alternative and
add connection loop recovery steps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 14:07:21 +02:00
MarekWo b18c0145dd docs(ble): add pairing guide, remove unused MC_BLE_PIN config
MC_BLE_PIN was non-functional — bleak in Docker cannot perform
interactive pairing (no BlueZ agent). Pairing must be done on
the host before starting mc-webui. Added comprehensive pairing
guide at docs/meshcore_bluetooth_pairing.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 13:56:45 +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 bd59826504 docs: add DM retry logic guide for users
Explains the 4 delivery scenarios, how settings map to behavior,
why actual wait times can exceed configured intervals (firmware
suggested_timeout), and what to look for in the System Log.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 20:46:28 +01:00
MarekWo 701f6f1197 fix(dm): refresh mc.contacts from device on PATH_UPDATE event
The Contact Info dialog showed stale path data (e.g. "Flood" instead of
the discovered route) because auto_update_contacts is OFF and PATH_UPDATE
only sets _contacts_dirty=True without refreshing mc.contacts. The API
then served stale in-memory data even after cache invalidation.

Now ensure_contacts(follow=True) is called on PATH_UPDATE to read fresh
contact data from the device before invalidating cache and emitting the
socket event. PATH_UPDATE events are rare (only on path discovery), so
the serial I/O cost is acceptable unlike advertisements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 17:52:41 +01:00
MarekWo 0b3bd1da60 fix(dm): delayed path backfill for FLOOD-delivered messages
When FLOOD delivery is confirmed, the PATH_UPDATE event payload often
has empty path data because firmware updates the contact's out_path
asynchronously. After 3s delay, read the contact's updated path from
the meshcore library's in-memory contacts dict and backfill the DB.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 15:23:35 +01:00
MarekWo 4de6d72cfe fix(dm): update delivery path from PATH event after ACK race
When both ACK and PATH_UPDATE fire for FLOOD delivery, _on_ack may
store empty path before PATH_UPDATE can provide the discovered route.
Now _on_path_update also checks for recently-delivered DMs with empty
delivery_path and backfills with the discovered path from the event.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 15:05:35 +01:00
MarekWo 58af37238b fix(ui): move retry counter above Resend button, same line as delivery info
Retry counter now renders as a dm-delivery-meta div above the Resend
button instead of inline next to it, matching the position of the
post-delivery info. Prevents text from crowding the button on short
messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 14:58:31 +01:00
MarekWo f135c90e61 fix(ui): align DM route popup to the right to prevent overflow
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 14:48:27 +01:00
MarekWo 90c1c90ba3 feat(dm): clickable route popup for long delivery paths
Long routes (>4 hops) show truncated with dotted underline; clicking
opens a popup with the full route and hop count, same style as channel
message path popups. Short routes (<=4 hops) display inline as before.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 14:44:40 +01:00
MarekWo 7d8a3c895d fix(dm): use discovered path from PATH event for delivery route
When PATH_UPDATE confirms delivery, use the actual path from the
event data instead of the empty path_desc from _retry_context (which
is empty during FLOOD phase). This captures the route firmware
discovered via the flood delivery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 14:39:53 +01:00
MarekWo 3c7f70175f fix(dm): handle FLOOD delivery and old DIRECT path gracefully
Add hex validation to formatDmRoute to avoid garbling old "DIRECT"
values. When no hex route available (FLOOD delivery), fall back to
delivery_route from ACK (e.g. show "FLOOD" stripped of PATH_ prefix).
Ensures delivery meta always shows something useful.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 14:28:40 +01:00
MarekWo 7a44d3b95d fix(dm): resolve race condition — delivery info stored before task cancel
The _on_ack handler cancels the retry task before _retry() can store
delivery info (attempt count, path). Fix by maintaining a _retry_context
dict updated before each send. _on_ack reads context and stores delivery
info + emits dm_delivered_info BEFORE cancelling the task. Same fix
applied to PATH_UPDATE backup delivery handler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 14:11:05 +01:00
MarekWo 885a967348 fix(dm): show delivery route as hex path, add real-time delivery info
Store actual hex path instead of DIRECT/FLOOD labels in delivery_path.
Format route as AB→CD→EF (same as channel messages, truncated if >4
hops). Add dm_delivered_info WebSocket event so delivery meta appears
in real-time without needing page reload. Remove path info from failed
messages since it's not meaningful for undelivered messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 13:21:53 +01:00
MarekWo 677036a831 fix(dm): move retry counter below message, show delivery info visually
Move the attempt counter (e.g. "Attempt 15/24") from next to the status
icon to below the message text, left of the Resend button. Add visible
delivery meta line for delivered/failed messages showing attempt count
and path used. Store attempt info for failed messages too. Replace
Polish abbreviations (ŚK, ŚD, ŚG) with English in all log messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 12:52:00 +01: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
MarekWo d2e019fa0e refactor(dm): restructure retry logic into 4-scenario matrix
Replace 3-way branching (configured_paths/has_path/else) with
4-scenario matrix based on (has_path × has_configured_paths):

- S1: No path, no configured paths → FLOOD only
- S2: Has path, no configured paths → DIRECT + optional FLOOD
- S3: No path, has configured paths → FLOOD first, then ŚD rotation
- S4: Has path, has configured paths → DIRECT on ŚK, ŚD rotation, optional FLOOD

Key changes:
- S3: FLOOD before configured paths (discover new routes)
- S4: exhaust retries on current ŚK before rotating ŚD
- S4: dedup ŚG/ŚK to skip redundant retries on same path
- Add _paths_match() helper for path deduplication
- Update tooltip text for settings clarity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 11:55:17 +01:00
MarekWo 9be7ae6cc4 fix(ui): always refresh contact data on path_changed event
The path_changed socket handler was skipping the refresh when Contact
Info modal was closed. This meant contactsList stayed stale, so opening
the modal later still showed outdated path info. Now always refreshes
contactsList on any path_changed event.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 07:32:33 +01:00
MarekWo 5df9b4b4a2 fix(ui): refresh Contact Info path display in real-time
Path info in Contact Info modal was stale due to 60s server cache
and no refresh after path operations. Now:
- Invalidate contacts cache after reset_path, change_path, path_update
- Emit 'path_changed' socket event on PATH_UPDATE from device
- UI listens and re-renders Contact Info when path changes
- Reset to FLOOD button immediately refreshes the path display

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 20:29:26 +01:00
MarekWo 292d1d91af fix(contacts): restore flexbox list height, remove calc() overrides
The contact list in Existing/Pending Contacts was not using all available
space due to calc(100vh - ...) and max-height rules overriding the
flexbox layout. Remove fixed height constraints from #pendingList and
#existingList in both contacts_base.html and style.css, letting the
flexbox chain (body > main > container > pageContent > list) fill the
remaining viewport space.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 07:54:47 +01:00
MarekWo 054b80926d docs: update documentation for path management, add contact, theme, sidebar
- README: add multi-path routing, add contact via URI/QR, dark/light theme,
  desktop sidebar, device share tab, pubkey-based DB naming
- User Guide: add sections for Adding Contacts (URI/QR/manual), DM Path
  Management (multi-path, repeater picker, map picker, keep path toggle),
  Device Share tab, theme setting, desktop sidebar notes
- Architecture: add path management API endpoints (CRUD, reorder, reset,
  no_auto_flood), manual-add, push-to-device, move-to-cache endpoints,
  update DB naming to pubkey prefix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 18:08:58 +01:00
MarekWo 54be1796f8 fix(ui): reduce DM sidebar contact name font size to match channel sidebar
Global .contact-name (1.1rem/600) was bleeding into DM sidebar items.
Added explicit 0.88rem/400 override for .dm-sidebar-item .contact-name.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 17:51:26 +01:00
MarekWo 71e00caa55 feat(ui): add dark/light theme switching with Settings toggle
- Create theme.css with CSS custom properties for light/dark themes
- Dark theme inspired by demo landing page (deep navy palette)
- Update style.css: replace ~145 hardcoded colors with CSS variables
- Extract inline styles from index.html, contacts.html, dm.html to style.css
- Add Appearance tab in Settings modal with theme selector
- Bootstrap 5.3 data-bs-theme integration for native dark mode
- Theme persisted in localStorage, applied before CSS loads (no FOUC)
- Console and System Log panels unchanged (already dark themed)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 08:23:26 +01:00
MarekWo 2e6f0d01d6 feat(device): add Share tab with QR code and URI for sharing own contact
Adds a Share tab to the Device Info modal that generates a QR code
and copyable URI (meshcore://contact/add?...) for sharing the device
contact with other users.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 09:12:30 +01:00
MarekWo ce88ec291f fix(dm): preserve sidebar search filter when conversations refresh
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 07:59:28 +01:00
MarekWo c6eb2b1755 fix(dm): remove d-none class that conflicts with media query on desktop header
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 07:57:54 +01:00
MarekWo 1e768e799b feat(ui): add channel/contact sidebar for wide screens (desktop/tablet)
On screens >= 992px (lg breakpoint), show a persistent sidebar panel:
- Group chat: channel list with unread badges, active highlight, muted state
- DM: conversation/contact list with search, unread dots, type badges
- Desktop contact header with info button replaces mobile selector
- Mobile/narrow screens unchanged (dropdown/top selector still used)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 07:56:32 +01:00
MarekWo 7b2f721d1d fix(contacts): wrap long public keys in add contact previews
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 21:43:28 +01:00
MarekWo 17b3c1c89c fix(contacts): correct COM type label from Communicator to Companion
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 21:01:12 +01:00
MarekWo 878d489661 feat(contacts): add contact UI with URI paste, QR scan, and manual entry
Stage 2 of manual contact add feature:
- POST /api/contacts/manual-add endpoint (URI or raw params)
- New /contacts/add page with 3 input tabs (URI, QR code, Manual)
- QR scanning via html5-qrcode (camera + image upload fallback)
- Client-side URI parsing with preview before submission
- Nav card in Contact Management above Pending Contacts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 20:54:41 +01:00
MarekWo 0973d2d714 fix(contacts): invalidate contacts cache after push/move operations
The /api/contacts/detailed endpoint has a 60s cache. Without invalidation
after push-to-device or move-to-cache, the UI showed stale data until
cache expired, making it look like the operation didn't work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 18:25:03 +01:00
MarekWo 9ee63188d2 feat(contacts): add push-to-device and move-to-cache operations
Enable moving contacts between device and cache directly from the
Existing Contacts UI:
- "To device" button on cache-only contacts (pushes to device)
- "To cache" button on device contacts (removes from device, keeps in DB)

This helps manage the 350-contact device limit by offloading inactive
contacts to cache and restoring them when needed.

- Add DeviceManager.push_to_device() and move_to_cache() methods
- Add API endpoints: POST /contacts/<pk>/push-to-device, move-to-cache
- Add UI buttons with confirm dialogs in contacts.js

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 18:06:26 +01:00
MarekWo 215515fe02 fix(contacts): add missing out_path_hash_mode field for manual_add
The meshcore library's update_contact() reads out_path_hash_mode directly
from the contact dict. Without it, add_contact_manual() fails with
KeyError: 'out_path_hash_mode'. Default value 0 is correct for new
contacts with no known path (flood mode).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 17:58:06 +01:00
MarekWo 3e8eb00e3e feat(contacts): add manual_add command for adding contacts from URI or params
Add support for adding contacts manually using the MeshCore mobile app URI
format (meshcore://contact/add?name=...&public_key=...&type=...) or raw
parameters (public_key, type, name). This enables contact sharing between
mc-webui and the MeshCore Android/iOS app via URI/QR codes.

- Add parse_meshcore_uri() helper to parse mobile app URIs
- Add DeviceManager.add_contact_manual() using CMD_ADD_UPDATE_CONTACT
- Update import_contact_uri() to handle both mobile app and hex blob URIs
- Add manual_add console command with two usage variants
- Update console help text

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 17:52:22 +01:00
MarekWo d54d8f58dd fix(console): fix node_discover display using correct payload fields
The DISCOVER_RESPONSE payload uses 'pubkey' and 'node_type', not
'public_key'/'name'/'adv_name'. Now shows pubkey prefix, resolved
contact name, node type, SNR, and RSSI. Also rename CLI->COM type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 14:17:42 +01:00
MarekWo 2c73e20775 fix(backup): use DB filename as backup prefix instead of hardcoded 'mc-webui'
Backup filenames now derive from the active DB stem (e.g. mc_9cebbd27.2026-03-24.db).
Listing and cleanup glob *.db so existing mc-webui.* backups remain visible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 08:42:31 +01:00
MarekWo f9bcbabb86 fix: use Flask current_app for DB access in read_status and contacts_cache
'from app.main import db' gets None because python -m app.main loads the
module as __main__, creating a separate module instance from app.main.
Use current_app.db (Flask app context) instead — same pattern as api.py.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 08:26:07 +01:00
MarekWo 5ccd882c5a refactor: eliminate JSONL companion files, delegate to DB
Remove contacts_cache.jsonl and adverts.jsonl file I/O — all contact
data is already in the SQLite contacts/advertisements tables. Clean up
stale JSONL files (acks, echoes, path, dm_sent) at startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 08:16:41 +01:00
MarekWo 2a9f90c01d refactor: migrate read_status from JSON file to SQLite database
Replace file-based .read_status.json with DB-backed read_status table.
One-time migration imports existing data at startup. The read_status.py
module keeps the same public API so route handlers need no changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 08:13:26 +01:00
MarekWo acfa5d3550 refactor: use public key prefix for DB filename instead of device name
DB filename changes from {device_name}.db to mc_{pubkey[:8]}.db,
making it stable across device renames and preparing for multi-device support.
Existing databases are auto-migrated at startup by probing the device table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 08:11:20 +01:00
41 changed files with 9987 additions and 2073 deletions
+9 -2
View File
@@ -4,8 +4,8 @@
# ============================================
# MeshCore Device Connection
# ============================================
# Two transport options: Serial (USB) or TCP (network).
# Set MC_TCP_HOST to use TCP; leave empty to use serial.
# Three transport options: Serial (USB), TCP (network), or BLE (Bluetooth).
# Priority: BLE > TCP > Serial. Set the relevant variable to activate.
# --- Option A: Serial (default) ---
# Use "auto" for automatic detection (recommended if only one USB device)
@@ -19,6 +19,13 @@ MC_SERIAL_PORT=auto
# MC_TCP_HOST=192.168.1.100
# MC_TCP_PORT=5555
# --- Option C: BLE (Bluetooth Low Energy companion devices) ---
# Requires: USB BLE dongle on host, BlueZ installed.
# The device MUST be paired and trusted on the host BEFORE starting mc-webui.
# See docs/meshcore_bluetooth_pairing.md for detailed setup instructions.
# When MC_BLE_ADDRESS is set, serial and TCP are ignored.
# MC_BLE_ADDRESS=AA:BB:CC:DD:EE:FF
# Your MeshCore device name (used for .msgs file)
# Use "auto" for automatic detection from device (recommended)
# Or specify manually: MarWoj, SP5XYZ, MyNode
+87
View File
@@ -0,0 +1,87 @@
name: Build and Push mc-webui image
on:
push:
branches: [main, dev]
workflow_dispatch: # Manual trigger
env:
IMAGE_NAME: mc-webui
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Freeze App Version
run: python3 -m app.version freeze
- name: Extract version tag
id: get_tag
run: |
TAG_VALUE=$(grep "DOCKER_TAG =" app/version_frozen.py | cut -d'"' -f2)
echo "Extracted Tag: $TAG_VALUE"
echo "tag=$TAG_VALUE" >> $GITHUB_OUTPUT
- name: Determine image tags
id: tags
run: |
BRANCH="${GITHUB_REF##*/}"
DH_IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}"
OWNER_LC=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
GH_IMAGE="ghcr.io/${OWNER_LC}/${{ env.IMAGE_NAME }}"
TAG="${{ steps.get_tag.outputs.tag }}"
if [ "$BRANCH" = "main" ]; then
TAGS="${DH_IMAGE}:latest,${DH_IMAGE}:${TAG}"
TAGS="${TAGS},${GH_IMAGE}:latest,${GH_IMAGE}:${TAG}"
else
TAGS="${DH_IMAGE}:${BRANCH},${DH_IMAGE}:${BRANCH}-${TAG}"
TAGS="${TAGS},${GH_IMAGE}:${BRANCH},${GH_IMAGE}:${BRANCH}-${TAG}"
fi
echo "Generated tags: $TAGS"
echo "tags=$TAGS" >> $GITHUB_OUTPUT
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: ${{ steps.tags.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
+20 -4
View File
@@ -3,10 +3,17 @@
FROM python:3.11-slim
# Install system deps: curl (healthcheck), udev (serial device support)
# Install runtime + build deps, pip install, then remove build-only packages
# Build deps (gcc, *-dev) needed for Pillow/pycryptodome wheels on ARM
RUN apt-get update && apt-get install -y \
curl \
udev \
bluez \
dbus \
gcc \
python3-dev \
libjpeg-dev \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
# Set working directory
@@ -15,8 +22,11 @@ WORKDIR /app
# Copy requirements first for better layer caching
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Install Python dependencies, then remove build-only packages
RUN pip install --no-cache-dir -r requirements.txt \
&& apt-get purge -y gcc python3-dev libjpeg-dev zlib1g-dev \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
# Copy application code
# Note: Run 'python -m app.version freeze' before build to include version info
@@ -30,5 +40,11 @@ ENV FLASK_HOST=0.0.0.0
ENV FLASK_PORT=5000
ENV FLASK_DEBUG=false
# Run the application
# Entrypoint: disconnect stale BLE connections before starting the app.
# BlueZ auto-reconnects trusted devices, leaving stale GATT notification
# handles that block bleak from establishing a new session.
COPY scripts/docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["python", "-m", "app.main"]
+172 -18
View File
@@ -13,25 +13,28 @@ A lightweight web interface providing browser-based access to MeshCore mesh netw
## Key Features
- **Mobile-first design** - Responsive UI optimized for small screens
- **Mobile-first design** - Responsive UI optimized for small screens, with desktop sidebar for wide screens
- **Channel management** - Create, join, share (QR code), and switch between encrypted channels
- **Direct Messages (DM)** - Private messaging with searchable contact selector, delivery tracking, and configurable retry strategy
- **Direct Messages (DM)** - Private messaging with searchable contact selector, delivery tracking, configurable retry strategy, and multi-path routing
- **Smart notifications** - Unread message counters per channel with cross-device sync
- **Contact management** - Manual approval mode, filtering, protection, ignoring, blocking, batch operations, and cleanup tools
- **Contact management** - Manual approval, add via URI/QR, filtering, protection, ignoring, blocking, batch operations, and cleanup tools
- **Global search** - Full-text search across all messages (channels and DMs) with FTS5 backend
- **Database** - Fast and reliable SQLite storage for messages, contacts, and configurations
- **Contact map** - View contacts and own device on OpenStreetMap (Leaflet) with last seen info
- **Message archives** - Automatic daily archiving with browse-by-date selector
- **Interactive Console** - Full MeshCore command suite via WebSocket — repeater, contact, device, and channel management
- **Device dashboard** - Device info and statistics with firmware details
- **Settings** - Configurable DM retry parameters, message retention, and quote length
- **Device dashboard** - Device info, statistics, and contact sharing (QR code / URI)
- **Device configuration** - Edit device name, GPS coordinates (with map picker), advert location sharing, and LoRa radio parameters (frequency, bandwidth, SF, CR, TX power) directly from Settings
- **Quick-access FAB buttons** - Draggable floating action buttons (Filter, Search, DM, Contacts, Settings) on main chat and DM pages, with collapsible visibility and customizable size/spacing
- **Dark/Light theme** - Toggle between dark and light UI themes
- **Settings** - Configurable DM retry parameters, message retention, quote length, route popup timeout, toast notification position/timeout, and theme
- **System Log** - Real-time log viewer with streaming
- **Database backup** - Create, list, and download database backups from the UI
- **@Mentions autocomplete** - Type @ to see contact suggestions with fuzzy search
- **Echo tracking** - "Heard X repeats" with repeater IDs for sent messages, all route paths for incoming messages with deterministic payload matching (persisted across restarts)
- **MeshCore Analyzer** - View packet details on analyzer.letsmesh.net directly from channel messages
- **DM delivery tracking** - ACK-based delivery confirmation with SNR and route info
- **Multi-device support** - Database file named after device for easy multi-device setups
- **DM delivery tracking** - ACK-based delivery confirmation with SNR, route, and hop count details
- **Multi-device support** - Database file named after device public key for easy multi-device setups
- **PWA support** - Browser notifications and installable app (experimental)
- **Full offline support** - Works without internet (local Bootstrap, icons, emoji picker)
@@ -42,12 +45,13 @@ For detailed feature documentation, see the [User Guide](docs/user-guide.md).
### Prerequisites
**1. Meshcore Device (tested on Heltec V4)**
- Flash the device at [https://flasher.meshcore.co.uk/](https://flasher.meshcore.co.uk/). Choose the `Companion USB` role.
- Flash the device at [https://flasher.meshcore.co.uk/](https://flasher.meshcore.co.uk/). Choose the `Companion USB` role (or `Companion BLE` if you plan to use Bluetooth).
- Configure the device with the Meshcore mobile app (from Google Play Store / App Store): Name, Location (optional), Preset
**2. Linux Server**
- Git installed
**2. Linux Server (or Raspberry Pi)**
- Docker and Docker Compose installed ([installation guide](docs/docker-install.md))
- Git installed (only needed for Option B: From Source)
- Supported architectures: `linux/amd64`, `linux/arm64`, `linux/arm/v7` (Raspberry Pi 2/3/4/5 all work)
**Important Notes:**
- Powered by direct meshcore library integration (v2 architecture)
@@ -56,7 +60,136 @@ For detailed feature documentation, see the [User Guide](docs/user-guide.md).
---
### Installation
### Option A: Docker Hub (recommended)
The quickest way to get started — no git clone, no building required.
Available image tags:
- `mawoj/mc-webui:latest` — stable release (from `main` branch)
- `mawoj/mc-webui:dev` — latest development version (from `dev` branch)
Images are also mirrored to GitHub Container Registry: `ghcr.io/marekwo/mc-webui`
1. **Create a project directory**
```bash
mkdir ~/mc-webui && cd ~/mc-webui
```
2. **Create `docker-compose.yml`**
```bash
cat > docker-compose.yml << 'EOF'
services:
mc-webui:
image: mawoj/mc-webui:latest
container_name: mc-webui
restart: unless-stopped
ports:
- "${FLASK_PORT:-5000}:${FLASK_PORT:-5000}"
device_cgroup_rules:
- 'c 188:* rmw'
- 'c 166:* rmw'
cap_add:
- NET_ADMIN
- NET_RAW
volumes:
- "${MC_CONFIG_DIR:-./data}:/data:rw"
- "/dev:/dev"
- "/var/run/dbus:/var/run/dbus"
environment:
- MC_SERIAL_PORT=${MC_SERIAL_PORT:-auto}
- MC_DEVICE_NAME=${MC_DEVICE_NAME:-MeshCore}
- MC_CONFIG_DIR=/data
- MC_TCP_HOST=${MC_TCP_HOST:-}
- MC_TCP_PORT=${MC_TCP_PORT:-5555}
- MC_BLE_ADDRESS=${MC_BLE_ADDRESS:-}
- MC_BACKUP_ENABLED=${MC_BACKUP_ENABLED:-true}
- MC_BACKUP_HOUR=${MC_BACKUP_HOUR:-2}
- MC_BACKUP_RETENTION_DAYS=${MC_BACKUP_RETENTION_DAYS:-7}
- FLASK_HOST=${FLASK_HOST:-0.0.0.0}
- FLASK_PORT=${FLASK_PORT:-5000}
- FLASK_DEBUG=${FLASK_DEBUG:-false}
- TZ=${TZ:-UTC}
env_file:
- path: .env
required: false
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
EOF
```
3. **Create `.env` file (optional)**
**In most cases, no `.env` file is needed!** The defaults work automatically:
- `MC_SERIAL_PORT=auto` — auto-detects your USB device
- `MC_DEVICE_NAME=auto` — auto-detects device name
If you want to set your timezone or override defaults:
```bash
echo "TZ=Europe/Warsaw" > .env
```
<details>
<summary><b>Troubleshooting: Multiple USB devices or detection fails</b></summary>
Check available serial devices:
```bash
ls /dev/serial/by-id/
```
If you see multiple devices, add to your `.env`:
```bash
MC_SERIAL_PORT=/dev/serial/by-id/usb-Espressif_Systems_heltec_...
```
</details>
4. **Verify Serial Device Permissions** (if needed)
```bash
sudo usermod -aG dialout $USER
# Log out and log back in for changes to take effect
```
5. **Start mc-webui**
```bash
docker compose up -d
```
This will:
- Pull the pre-built mc-webui image from Docker Hub
- Create `./data/` directory structure automatically
- Start the mc-webui container
6. **Verify installation**
```bash
docker compose ps
```
The container should show `Up` status. Check logs if needed:
```bash
docker compose logs -f
```
7. **Access the web interface**
Open your browser and navigate to:
```
http://<your-server-ip>:5000
```
To find your server IP: `hostname -I | awk '{print $1}'`
8. **Initial Configuration (In Web UI)**
- Main page loads with chat interface on "Public" channel
- Wait for initial sync (1-2 minutes)
- Optional: Enable manual contact approval in Contact Management
### Option B: From Source (Git Clone)
Choose this option if you want to modify the code, contribute, or run the `dev` branch.
1. **Clone the repository**
```bash
@@ -152,7 +285,21 @@ For complete usage instructions, see the [User Guide](docs/user-guide.md).
## Updating
### Using the update script (recommended)
### Docker Hub installation
Pull the latest image and restart:
```bash
cd ~/mc-webui
docker compose pull
docker compose up -d
```
To switch between stable and dev, change the image tag in `docker-compose.yml`:
- Stable: `image: mawoj/mc-webui:latest`
- Development: `image: mawoj/mc-webui:dev`
### From Source: Using the update script (recommended)
The easiest way to update mc-webui:
@@ -299,6 +446,8 @@ sudo ~/mc-webui/scripts/updater/install.sh --uninstall
| [Troubleshooting](docs/troubleshooting.md) | Common issues and solutions |
| [Docker Installation](docs/docker-install.md) | How to install Docker on Debian/Ubuntu |
| [Container Watchdog](docs/watchdog.md) | Auto-restart for unhealthy containers |
| [DM Delivery & Retry Logic](docs/dm-retry-logic.md) | How DM delivery confirmation and retry strategies work |
| [Bluetooth Pairing Guide](docs/meshcore_bluetooth_pairing.md) | How to pair MeshCore devices via BLE on Linux |
---
@@ -311,12 +460,12 @@ sudo ~/mc-webui/scripts/updater/install.sh --uninstall
- [x] Frontend Chat View (Bootstrap UI, message display, quote/reply)
- [x] Message Sending (Send form, reply, quote with configurable length)
- [x] Intelligent Auto-refresh (10s checks, UI updates only when needed)
- [x] Contact Management (Approval, filtering, protection, ignore/block, batch operations, cleanup)
- [x] Contact Management (Approval, add via URI/QR, filtering, protection, ignore/block, batch operations, cleanup)
- [x] Channel Management (Create, join, share via QR, delete with auto-cleanup)
- [x] Public Channels (# prefix support, auto-key generation)
- [x] Message Archiving (Daily archiving with browse-by-date selector)
- [x] Smart Notifications (Unread counters per channel and total)
- [x] Direct Messages (DM) - Searchable contact selector, delivery tracking, configurable retry
- [x] Direct Messages (DM) - Searchable contact selector, delivery tracking, configurable retry, multi-path routing
- [x] Global Message Search - Full-text search across channels and DMs (FTS5)
- [x] Message Content Enhancements - Mention badges, clickable URLs, image previews
- [x] @Mentions Autocomplete - Type @ to get contact suggestions with fuzzy search
@@ -327,11 +476,16 @@ sudo ~/mc-webui/scripts/updater/install.sh --uninstall
- [x] Echo Tracking - "Heard X repeats" badge for sent channel messages
- [x] MeshCore Analyzer - Packet analysis links on channel messages (analyzer.letsmesh.net)
- [x] DM Delivery Tracking - ACK-based delivery checkmarks with SNR/route details
- [x] Device Dashboard - Device info and statistics with firmware details
- [x] Settings Modal - Configurable DM retry parameters and message retention
- [x] Device Dashboard - Device info, statistics, and contact sharing (QR/URI)
- [x] Device Configuration - Edit device name, coordinates (map picker), radio parameters from Settings
- [x] Settings Modal - DM retry parameters, message retention, route popup / toast customization, and dark/light theme
- [x] Quick-Access FAB Buttons - Draggable floating buttons with collapse toggle and size/spacing controls
- [x] System Log - Real-time log viewer with streaming
- [x] Database Backup - Create, list, and download backups from the UI
- [x] Multi-device Support - Database file named after device name
- [x] Desktop Sidebar - Channel/contact sidebar for wide screens (tablet/desktop)
- [x] Dark/Light Theme - Toggle between dark and light UI themes
- [x] Multi-device Support - Database file named after device public key
- [x] Multi-arch Docker Images - amd64, arm64, arm/v7 (Raspberry Pi supported)
### Next Steps
@@ -365,7 +519,7 @@ This is an open-source project. Contributions are welcome!
## References
- [MeshCore Documentation](https://meshcore.org)
- [meshcore-cli GitHub](https://github.com/meshcore-dev/meshcore-cli)
- [meshcore Python library](https://pypi.org/project/meshcore/)
---
+25 -2
View File
@@ -26,12 +26,16 @@ class Config:
MC_ARCHIVE_RETENTION_DAYS = int(os.getenv('MC_ARCHIVE_RETENTION_DAYS', '7'))
# v2: Database
MC_DB_PATH = os.getenv('MC_DB_PATH', '') # empty = auto: {MC_CONFIG_DIR}/{device_name}.db
MC_DB_PATH = os.getenv('MC_DB_PATH', '') # empty = auto: {MC_CONFIG_DIR}/mc_{pubkey_prefix}.db
# v2: TCP connection (alternative to serial, e.g. meshcore-proxy)
MC_TCP_HOST = os.getenv('MC_TCP_HOST', '') # empty = use serial
MC_TCP_PORT = int(os.getenv('MC_TCP_PORT', '5555'))
# v2: BLE connection (alternative to serial/TCP, for BLE companion devices)
# Device must be paired and trusted on host before starting (see docs/meshcore_bluetooth_pairing.md)
MC_BLE_ADDRESS = os.getenv('MC_BLE_ADDRESS', '') # BLE MAC address or device name filter
# v2: Backup
MC_BACKUP_ENABLED = os.getenv('MC_BACKUP_ENABLED', 'true').lower() == 'true'
MC_BACKUP_HOUR = int(os.getenv('MC_BACKUP_HOUR', '2'))
@@ -64,13 +68,32 @@ class Config:
return Path(self.MC_DB_PATH)
return Path(self.MC_CONFIG_DIR) / 'mc-webui.db'
@property
def use_ble(self) -> bool:
"""True if BLE transport should be used (highest priority)"""
return bool(self.MC_BLE_ADDRESS)
@property
def use_tcp(self) -> bool:
"""True if TCP transport should be used instead of serial"""
return bool(self.MC_TCP_HOST)
@property
def transport_type(self) -> str:
"""Return active transport type: 'ble', 'tcp', or 'serial'"""
if self.use_ble:
return 'ble'
if self.use_tcp:
return 'tcp'
return 'serial'
def __repr__(self):
transport = f"tcp={self.MC_TCP_HOST}:{self.MC_TCP_PORT}" if self.use_tcp else f"serial={self.MC_SERIAL_PORT}"
if self.use_ble:
transport = f"ble={self.MC_BLE_ADDRESS}"
elif self.use_tcp:
transport = f"tcp={self.MC_TCP_HOST}:{self.MC_TCP_PORT}"
else:
transport = f"serial={self.MC_SERIAL_PORT}"
return (
f"Config(device={self.MC_DEVICE_NAME}, "
f"{transport}, "
+36 -202
View File
@@ -1,159 +1,59 @@
"""
Contacts Cache - Persistent storage of all known node names + public keys.
Contacts Cache - DB-backed contact name/key lookup.
Stores every node name ever seen (from device contacts and adverts),
so @mention autocomplete works even for removed contacts.
All contact data is stored in the SQLite contacts table.
JSONL files are no longer used.
File format: JSONL ({device_name}.contacts_cache.jsonl)
Each line: {"public_key": "...", "name": "...", "first_seen": ts, "last_seen": ts,
"source": "advert"|"device", "lat": float, "lon": float, "type_label": "COM"|"REP"|...}
Kept for backward compatibility: get_all_names(), get_all_contacts(),
parse_advert_payload().
"""
import json
import logging
import math
import struct
import time
from pathlib import Path
from threading import Lock
from app.config import config, runtime_config
from flask import current_app
logger = logging.getLogger(__name__)
_cache_lock = Lock()
_cache: dict = {} # {public_key: {name, first_seen, last_seen, source}}
_cache_loaded = False
_adverts_offset = 0 # File offset for incremental advert scanning
_TYPE_LABELS = {0: 'COM', 1: 'COM', 2: 'REP', 3: 'ROOM', 4: 'SENS'}
def _get_cache_path() -> Path:
device_name = runtime_config.get_device_name()
return Path(config.MC_CONFIG_DIR) / f"{device_name}.contacts_cache.jsonl"
def _get_adverts_path() -> Path:
device_name = runtime_config.get_device_name()
return Path(config.MC_CONFIG_DIR) / f"{device_name}.adverts.jsonl"
def load_cache() -> dict:
"""Load cache from disk into memory. Returns copy of cache dict."""
global _cache, _cache_loaded
with _cache_lock:
if _cache_loaded:
return _cache.copy()
cache_path = _get_cache_path()
_cache = {}
if not cache_path.exists():
_cache_loaded = True
logger.info("Contacts cache file does not exist yet")
return _cache.copy()
try:
with open(cache_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
pk = entry.get('public_key', '').lower()
if pk:
# Migrate old "CLI" label to "COM"
if entry.get('type_label') == 'CLI':
entry['type_label'] = 'COM'
_cache[pk] = entry
except json.JSONDecodeError:
continue
_cache_loaded = True
logger.info(f"Loaded contacts cache: {len(_cache)} entries")
except Exception as e:
logger.error(f"Failed to load contacts cache: {e}")
_cache_loaded = True
return _cache.copy()
def save_cache() -> bool:
"""Write full cache to disk (atomic write)."""
with _cache_lock:
cache_path = _get_cache_path()
try:
cache_path.parent.mkdir(parents=True, exist_ok=True)
temp_file = cache_path.with_suffix('.tmp')
with open(temp_file, 'w', encoding='utf-8') as f:
for entry in _cache.values():
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
temp_file.replace(cache_path)
logger.debug(f"Saved contacts cache: {len(_cache)} entries")
return True
except Exception as e:
logger.error(f"Failed to save contacts cache: {e}")
return False
def upsert_contact(public_key: str, name: str, source: str = "advert",
lat: float = 0.0, lon: float = 0.0, type_label: str = "") -> bool:
"""Add or update a contact in the cache. Returns True if cache was modified."""
pk = public_key.lower()
now = int(time.time())
with _cache_lock:
existing = _cache.get(pk)
if existing:
changed = False
if name and name != existing.get('name'):
existing['name'] = name
changed = True
# Update lat/lon if new values are non-zero
if lat != 0.0 or lon != 0.0:
if lat != existing.get('lat') or lon != existing.get('lon'):
existing['lat'] = lat
existing['lon'] = lon
changed = True
# Update type_label if provided and not already set
if type_label and type_label != existing.get('type_label'):
existing['type_label'] = type_label
changed = True
existing['last_seen'] = now
return changed
else:
if not name:
return False
entry = {
'public_key': pk,
'name': name,
'first_seen': now,
'last_seen': now,
'source': source,
}
if lat != 0.0 or lon != 0.0:
entry['lat'] = lat
entry['lon'] = lon
if type_label:
entry['type_label'] = type_label
_cache[pk] = entry
return True
def _get_db():
"""Get database instance from Flask app context."""
return getattr(current_app, 'db', None)
def get_all_contacts() -> list:
"""Get all cached contacts as a list of dicts (shallow copies)."""
with _cache_lock:
return [entry.copy() for entry in _cache.values()]
"""Get all known contacts from DB."""
try:
db = _get_db()
if db:
contacts = db.get_contacts()
return [{
'public_key': c.get('public_key', ''),
'name': c.get('name', ''),
'first_seen': c.get('first_seen', ''),
'last_seen': c.get('last_seen', ''),
'source': c.get('source', ''),
'lat': c.get('adv_lat', 0.0) or 0.0,
'lon': c.get('adv_lon', 0.0) or 0.0,
'type_label': _TYPE_LABELS.get(c.get('type', 1), 'UNKNOWN'),
} for c in contacts]
except Exception as e:
logger.error(f"Failed to get contacts: {e}")
return []
def get_all_names() -> list:
"""Get all unique non-empty contact names sorted alphabetically."""
with _cache_lock:
return sorted(set(
entry['name'] for entry in _cache.values()
if entry.get('name')
))
try:
db = _get_db()
if db:
contacts = db.get_contacts()
return sorted(set(c.get('name', '') for c in contacts if c.get('name')))
except Exception as e:
logger.error(f"Failed to get contact names: {e}")
return []
def parse_advert_payload(pkt_payload_hex: str):
@@ -208,69 +108,3 @@ def parse_advert_payload(pkt_payload_hex: str):
return public_key, node_name if node_name else None, lat, lon
except Exception:
return None, None, 0.0, 0.0
def scan_new_adverts() -> int:
"""
Scan .adverts.jsonl for new entries since last scan.
Returns number of new/updated contacts.
"""
global _adverts_offset
adverts_path = _get_adverts_path()
if not adverts_path.exists():
return 0
updated = 0
try:
with open(adverts_path, 'r', encoding='utf-8') as f:
f.seek(_adverts_offset)
for line in f:
line = line.strip()
if not line:
continue
try:
advert = json.loads(line)
pkt_payload = advert.get('pkt_payload', '')
if not pkt_payload:
continue
pk, name, lat, lon = parse_advert_payload(pkt_payload)
if pk and name:
if upsert_contact(pk, name, source="advert", lat=lat, lon=lon):
updated += 1
except json.JSONDecodeError:
continue
_adverts_offset = f.tell()
except Exception as e:
logger.error(f"Failed to scan adverts: {e}")
if updated > 0:
save_cache()
logger.info(f"Contacts cache updated: {updated} new/changed entries")
return updated
_TYPE_LABELS = {1: 'COM', 2: 'REP', 3: 'ROOM', 4: 'SENS'}
def initialize_from_device(contacts_detailed: dict):
"""
Seed cache from /api/contacts/detailed response dict.
Called once at startup if cache file doesn't exist.
Args:
contacts_detailed: dict of {public_key: {adv_name, type, adv_lat, adv_lon, ...}} from meshcli
"""
added = 0
for pk, details in contacts_detailed.items():
name = details.get('adv_name', '')
lat = details.get('adv_lat', 0.0) or 0.0
lon = details.get('adv_lon', 0.0) or 0.0
type_label = _TYPE_LABELS.get(details.get('type'), '')
if upsert_contact(pk, name, source="device", lat=lat, lon=lon, type_label=type_label):
added += 1
if added > 0:
save_cache()
logger.info(f"Initialized contacts cache from device: {added} contacts")
+205 -11
View File
@@ -44,6 +44,30 @@ class Database:
conn.execute("ALTER TABLE contacts ADD COLUMN no_auto_flood INTEGER DEFAULT 0")
logger.info("Migration: added contacts.no_auto_flood column")
# Add delivery tracking columns to direct_messages
dm_columns = {r[1] for r in conn.execute("PRAGMA table_info(direct_messages)").fetchall()}
for col, typedef in [
('delivery_status', 'TEXT'),
('delivery_attempt', 'INTEGER'),
('delivery_max_attempts', 'INTEGER'),
('delivery_path', 'TEXT'),
]:
if col not in dm_columns:
conn.execute(f"ALTER TABLE direct_messages ADD COLUMN {col} {typedef}")
logger.info(f"Migration: added direct_messages.{col} column")
# Add hash_size column to echoes (path_hash_mode support)
echo_columns = {r[1] for r in conn.execute("PRAGMA table_info(echoes)").fetchall()}
if 'hash_size' not in echo_columns:
conn.execute("ALTER TABLE echoes ADD COLUMN hash_size INTEGER NOT NULL DEFAULT 1")
logger.info("Migration: added echoes.hash_size column")
# Add is_favorite column to read_status (channel favorites)
rs_columns = {r[1] for r in conn.execute("PRAGMA table_info(read_status)").fetchall()}
if 'is_favorite' not in rs_columns:
conn.execute("ALTER TABLE read_status ADD COLUMN is_favorite INTEGER DEFAULT 0")
logger.info("Migration: added read_status.is_favorite column")
@contextmanager
def _connect(self):
"""Yield a connection with auto-commit/rollback."""
@@ -80,6 +104,12 @@ class Database:
row = conn.execute("SELECT * FROM device WHERE id = 1").fetchone()
return dict(row) if row else None
def get_public_key(self) -> Optional[str]:
"""Get device public key (used for DB filename resolution)."""
with self._connect() as conn:
row = conn.execute("SELECT public_key FROM device WHERE id = 1").fetchone()
return row['public_key'] if row and row['public_key'] else None
# ================================================================
# Contacts
# ================================================================
@@ -173,12 +203,16 @@ class Database:
return cursor.rowcount > 0
def hard_delete_contact(self, public_key: str) -> bool:
"""Permanently delete a contact from the database."""
"""Permanently delete a contact from the database.
Also clears ignored/blocked rows that reference this contact
via FK without ON DELETE CASCADE.
"""
pk = public_key.lower()
with self._connect() as conn:
cursor = conn.execute(
"DELETE FROM contacts WHERE public_key = ?",
(public_key.lower(),)
)
conn.execute("DELETE FROM ignored_contacts WHERE public_key = ?", (pk,))
conn.execute("DELETE FROM blocked_contacts WHERE public_key = ?", (pk,))
cursor = conn.execute("DELETE FROM contacts WHERE public_key = ?", (pk,))
return cursor.rowcount > 0
def downgrade_stale_device_contacts(self, active_device_keys: set) -> int:
@@ -454,6 +488,105 @@ class Database:
cursor = conn.execute("DELETE FROM channels WHERE idx = ?", (idx,))
return cursor.rowcount > 0
# ================================================================
# Regions (MeshCore flood scopes)
# ================================================================
def create_region(self, name: str, key_hex: str) -> int:
"""Insert a new region. Raises sqlite3.IntegrityError on duplicate name."""
with self._connect() as conn:
cursor = conn.execute(
"""INSERT INTO regions (name, key_hex) VALUES (?, ?)""",
(name, key_hex)
)
return cursor.lastrowid
def list_regions(self) -> List[Dict]:
with self._connect() as conn:
rows = conn.execute(
"SELECT * FROM regions ORDER BY name COLLATE NOCASE"
).fetchall()
return [dict(r) for r in rows]
def get_region(self, region_id: int) -> Optional[Dict]:
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM regions WHERE id = ?", (region_id,)
).fetchone()
return dict(row) if row else None
def get_region_by_name(self, name: str) -> Optional[Dict]:
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM regions WHERE name = ?", (name,)
).fetchone()
return dict(row) if row else None
def delete_region(self, region_id: int) -> bool:
with self._connect() as conn:
cursor = conn.execute("DELETE FROM regions WHERE id = ?", (region_id,))
return cursor.rowcount > 0
def set_default_region(self, region_id: Optional[int]) -> None:
"""Clear any existing default, then set the given region as default.
Passing None clears the default flag on all regions.
"""
with self._connect() as conn:
conn.execute("UPDATE regions SET is_default = 0, updated_at = datetime('now') WHERE is_default = 1")
if region_id is not None:
conn.execute(
"UPDATE regions SET is_default = 1, updated_at = datetime('now') WHERE id = ?",
(region_id,)
)
def get_default_region(self) -> Optional[Dict]:
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM regions WHERE is_default = 1 LIMIT 1"
).fetchone()
return dict(row) if row else None
def set_channel_scope(self, channel_idx: int, region_id: Optional[int]) -> None:
"""Set or clear the region mapping for a channel.
region_id=None removes the mapping (firmware default will apply).
"""
with self._connect() as conn:
if region_id is None:
conn.execute("DELETE FROM channel_scopes WHERE channel_idx = ?", (channel_idx,))
else:
conn.execute(
"""INSERT INTO channel_scopes (channel_idx, region_id)
VALUES (?, ?)
ON CONFLICT(channel_idx) DO UPDATE SET
region_id = excluded.region_id,
updated_at = datetime('now')""",
(channel_idx, region_id)
)
def get_channel_scope(self, channel_idx: int) -> Optional[Dict]:
"""Return the region dict assigned to this channel, or None."""
with self._connect() as conn:
row = conn.execute(
"""SELECT r.id AS region_id, r.name, r.key_hex, r.is_default
FROM channel_scopes cs
JOIN regions r ON r.id = cs.region_id
WHERE cs.channel_idx = ?""",
(channel_idx,)
).fetchone()
return dict(row) if row else None
def get_all_channel_scopes(self) -> Dict[int, Dict]:
"""Bulk-load the full channel->region mapping for UI rendering."""
with self._connect() as conn:
rows = conn.execute(
"""SELECT cs.channel_idx, r.id AS region_id, r.name, r.key_hex, r.is_default
FROM channel_scopes cs
JOIN regions r ON r.id = cs.region_id"""
).fetchall()
return {r['channel_idx']: dict(r) for r in rows}
# ================================================================
# Channel Messages
# ================================================================
@@ -654,6 +787,37 @@ class Database:
).fetchone()
return dict(row) if row else None
def update_dm_delivery_info(self, dm_id: int, attempt: int,
max_attempts: int, path: str):
"""Store successful delivery details (attempt number, path used)."""
with self._connect() as conn:
conn.execute(
"UPDATE direct_messages SET delivery_attempt=?, "
"delivery_max_attempts=?, delivery_path=? WHERE id=?",
(attempt, max_attempts, path, dm_id))
def update_dm_delivery_status(self, dm_id: int, status: str):
"""Mark message delivery as failed."""
with self._connect() as conn:
conn.execute(
"UPDATE direct_messages SET delivery_status=? WHERE id=?",
(status, dm_id))
def get_recent_delivered_dm_with_empty_path(self, contact_pubkey: str) -> Optional[Dict]:
"""Find most recent delivered outgoing DM with empty delivery_path."""
with self._connect() as conn:
row = conn.execute(
"""SELECT id, delivery_attempt, delivery_max_attempts
FROM direct_messages
WHERE contact_pubkey=? AND direction='out'
AND (delivery_path IS NULL OR delivery_path='')
AND delivery_status IS NULL
AND delivery_attempt IS NOT NULL
ORDER BY id DESC LIMIT 1""",
(contact_pubkey,)
).fetchone()
return dict(row) if row else None
def relink_orphaned_dms(self, public_key: str, name: str = '') -> int:
"""Re-link DMs with NULL contact_pubkey back to this contact.
@@ -719,13 +883,14 @@ class Database:
def insert_echo(self, pkt_payload: str, **kwargs) -> None:
with self._connect() as conn:
conn.execute(
"""INSERT INTO echoes (pkt_payload, path, snr, direction, cm_id)
VALUES (?, ?, ?, ?, ?)""",
"""INSERT INTO echoes (pkt_payload, path, snr, direction, cm_id, hash_size)
VALUES (?, ?, ?, ?, ?, ?)""",
(pkt_payload,
kwargs.get('path'),
kwargs.get('snr'),
kwargs.get('direction', 'incoming'),
kwargs.get('cm_id'))
kwargs.get('cm_id'),
kwargs.get('hash_size', 1))
)
def get_echoes_for_message(self, pkt_payload: str) -> List[Dict]:
@@ -963,6 +1128,34 @@ class Database:
(key, 1 if muted else 0)
)
def get_muted_channels(self) -> List[int]:
"""Get list of muted channel indices."""
with self._connect() as conn:
rows = conn.execute(
"SELECT key FROM read_status WHERE is_muted = 1 AND key LIKE 'chan_%'"
).fetchall()
return [int(r['key'][5:]) for r in rows]
def set_channel_favorite(self, channel_idx: int, favorite: bool) -> None:
key = f"chan_{channel_idx}"
with self._connect() as conn:
conn.execute(
"""INSERT INTO read_status (key, is_favorite)
VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET
is_favorite = excluded.is_favorite,
updated_at = datetime('now')""",
(key, 1 if favorite else 0)
)
def get_favorite_channels(self) -> List[int]:
"""Get list of favorite channel indices."""
with self._connect() as conn:
rows = conn.execute(
"SELECT key FROM read_status WHERE is_favorite = 1 AND key LIKE 'chan_%'"
).fetchall()
return [int(r['key'][5:]) for r in rows]
# ================================================================
# Full-Text Search
# ================================================================
@@ -1050,7 +1243,8 @@ class Database:
backup_dir.mkdir(parents=True, exist_ok=True)
date_str = datetime.now().strftime('%Y-%m-%d')
backup_path = backup_dir / f"mc-webui.{date_str}.db"
prefix = self.db_path.stem # e.g. "mc_9cebbd27"
backup_path = backup_dir / f"{prefix}.{date_str}.db"
source = sqlite3.connect(str(self.db_path))
dest = sqlite3.connect(str(backup_path))
@@ -1070,7 +1264,7 @@ class Database:
return []
backups = []
for f in sorted(backup_dir.glob("mc-webui.*.db"), reverse=True):
for f in sorted(backup_dir.glob("*.db"), reverse=True):
backups.append({
'filename': f.name,
'path': str(f),
@@ -1087,7 +1281,7 @@ class Database:
cutoff = datetime.now() - timedelta(days=retention_days)
removed = 0
for f in backup_dir.glob("mc-webui.*.db"):
for f in backup_dir.glob("*.db"):
if datetime.fromtimestamp(f.stat().st_mtime) < cutoff:
f.unlink()
removed += 1
+969 -139
View File
File diff suppressed because it is too large Load Diff
+223 -58
View File
@@ -8,14 +8,16 @@ import json
import logging
import re
import shlex
import sqlite3
import threading
import time
from pathlib import Path
from typing import Optional
from flask import Flask, request as flask_request
from flask_socketio import SocketIO, emit
from app.config import config, runtime_config
from app.database import Database
from app.device_manager import DeviceManager
from app.device_manager import DeviceManager, parse_meshcore_uri
from app.log_handler import MemoryLogHandler
from app.routes.views import views_bp
from app.routes.api import api_bp
@@ -52,21 +54,53 @@ db = None
device_manager = None
def _sanitize_db_name(name: str) -> str:
"""Sanitize device name for use as database filename."""
sanitized = re.sub(r'[<>:"/\\|?*\x00-\x1f]', '_', name)
sanitized = sanitized.strip('. ')
return sanitized or 'device'
def _pubkey_db_name(public_key: str) -> str:
"""Return stable DB filename based on device public key prefix."""
return f"mc_{public_key[:8].lower()}.db"
def _read_pubkey_from_db(db_path: Path) -> Optional[str]:
"""Probe an existing DB file for the device public key.
Uses a raw sqlite3 connection (not Database class) to avoid
WAL creation side effects on a file that may be about to be renamed.
"""
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
try:
row = conn.execute("SELECT public_key FROM device WHERE id = 1").fetchone()
if row and row[0]:
return row[0]
finally:
conn.close()
except Exception:
pass
return None
def _rename_db_files(src: Path, dst: Path) -> bool:
"""Rename DB + WAL + SHM files. Returns True on success."""
for suffix in ['', '-wal', '-shm']:
s = Path(str(src) + suffix)
d = Path(str(dst) + suffix)
if s.exists():
try:
s.rename(d)
except OSError as e:
logger.error(f"Failed to rename {s.name} -> {d.name}: {e}")
return False
return True
def _resolve_db_path() -> Path:
"""Resolve database path, preferring existing device-named DB files.
"""Resolve database path using public-key-based naming.
Priority:
1. Explicit MC_DB_PATH that is NOT mc-webui.db -> use as-is
2. Existing device-named .db file in config dir (most recently modified)
3. Existing mc-webui.db (legacy, will be renamed on device connect)
4. New mc-webui.db (will be renamed on device connect)
1. Explicit MC_DB_PATH (not mc-webui.db) -> use as-is
2. Existing mc_*.db file (new pubkey-based format) -> use most recent
3. Existing *.db (old device-name format) -> probe for pubkey, rename if possible
4. Existing mc-webui.db (legacy default) -> probe for pubkey, rename if possible
5. New install -> create mc-webui.db (will be renamed on first device connect)
"""
if config.MC_DB_PATH:
p = Path(config.MC_DB_PATH)
@@ -76,35 +110,69 @@ def _resolve_db_path() -> Path:
else:
db_dir = Path(config.MC_CONFIG_DIR)
# Scan for existing device-named DBs (anything except mc-webui.db)
# 1. Scan for new-format DBs (mc_????????.db)
try:
existing = sorted(
[f for f in db_dir.glob('*.db')
if f.name != 'mc-webui.db' and f.is_file()],
new_format = sorted(
[f for f in db_dir.glob('mc_????????.db') if f.is_file()],
key=lambda f: f.stat().st_mtime,
reverse=True
)
if existing:
logger.info(f"Found device-named database: {existing[0].name}")
return existing[0]
if new_format:
logger.info(f"Found database: {new_format[0].name}")
return new_format[0]
except OSError:
pass
# Fallback: mc-webui.db (legacy or new install)
return db_dir / 'mc-webui.db'
# 2. Scan for old device-named DBs (anything except mc-webui.db and mc_*.db)
try:
old_format = sorted(
[f for f in db_dir.glob('*.db')
if f.name != 'mc-webui.db'
and not re.match(r'^mc_[0-9a-f]{8}\.db$', f.name)
and f.is_file()],
key=lambda f: f.stat().st_mtime,
reverse=True
)
if old_format:
db_file = old_format[0]
pubkey = _read_pubkey_from_db(db_file)
if pubkey:
target = db_dir / _pubkey_db_name(pubkey)
if not target.exists() and _rename_db_files(db_file, target):
logger.info(f"Migrated database: {db_file.name} -> {target.name}")
return target
elif target.exists():
logger.info(f"Found database: {target.name}")
return target
# No pubkey in device table yet — use as-is, rename deferred
logger.info(f"Found legacy database: {db_file.name} (rename deferred)")
return db_file
except OSError:
pass
# 3. Check for mc-webui.db (legacy default)
legacy = db_dir / 'mc-webui.db'
if legacy.exists():
pubkey = _read_pubkey_from_db(legacy)
if pubkey:
target = db_dir / _pubkey_db_name(pubkey)
if not target.exists() and _rename_db_files(legacy, target):
logger.info(f"Migrated database: {legacy.name} -> {target.name}")
return target
return legacy
# 4. New install — will be renamed on first device connect
return legacy
def _migrate_db_to_device_name(db, device_name: str):
"""Rename DB file to match device name if needed.
def _migrate_db_to_pubkey(db, public_key: str):
"""Rename DB file to public-key-based name if needed.
Handles three cases:
- Current DB already matches device name -> no-op
- Target DB exists (different device was here before) -> switch to it
- Target DB doesn't exist -> rename current DB files
Called after device connects and provides its public key.
"""
safe_name = _sanitize_db_name(device_name)
target_name = _pubkey_db_name(public_key)
current = db.db_path
target = current.parent / f"{safe_name}.db"
target = current.parent / target_name
if current.resolve() == target.resolve():
return
@@ -123,19 +191,28 @@ def _migrate_db_to_device_name(db, device_name: str):
except Exception as e:
logger.warning(f"WAL checkpoint before rename: {e}")
# Rename DB + WAL + SHM files
for suffix in ['', '-wal', '-shm']:
src = Path(str(current) + suffix)
dst = Path(str(target) + suffix)
if src.exists():
try:
src.rename(dst)
except OSError as e:
logger.error(f"Failed to rename {src.name} -> {dst.name}: {e}")
return # abort migration
if _rename_db_files(current, target):
db.db_path = target
logger.info(f"Database renamed: {current.name} -> {target.name}")
db.db_path = target
logger.info(f"Database renamed: {current.name} -> {target.name}")
def _cleanup_legacy_jsonl(data_dir: Path):
"""Remove stale JSONL files whose data now lives in the database."""
patterns = [
'*.contacts_cache.jsonl',
'*.adverts.jsonl',
'*.acks.jsonl',
'*.echoes.jsonl',
'*.path.jsonl',
'*_dm_sent.jsonl',
]
for pattern in patterns:
for f in data_dir.glob(pattern):
try:
f.unlink()
logger.info(f"Removed legacy file: {f.name}")
except OSError as e:
logger.warning(f"Could not remove {f.name}: {e}")
def create_app():
@@ -148,10 +225,14 @@ def create_app():
app.config['DEBUG'] = config.FLASK_DEBUG
app.config['SECRET_KEY'] = 'mc-webui-secret-key-change-in-production'
# Inject version and branch into all templates
# Inject version, branch, and transport type into all templates
@app.context_processor
def inject_version():
return {'version': VERSION_STRING, 'git_branch': GIT_BRANCH}
def inject_globals():
return {
'version': VERSION_STRING,
'git_branch': GIT_BRANCH,
'transport_type': config.transport_type,
}
# Register blueprints
app.register_blueprint(views_bp)
@@ -186,6 +267,27 @@ def create_app():
except Exception as e:
logger.warning(f"Could not rename settings file: {e}")
# Migrate .read_status.json to DB (one-time)
read_status_file = Path(config.MC_CONFIG_DIR) / '.read_status.json'
if read_status_file.exists():
try:
import json as _json
with open(read_status_file, 'r', encoding='utf-8') as f:
rs_data = _json.load(f)
migrated = 0
for ch_idx, ts in rs_data.get('channels', {}).items():
db.mark_read(f"chan_{ch_idx}", int(ts))
migrated += 1
for conv_id, ts in rs_data.get('dm', {}).items():
db.mark_read(f"dm_{conv_id}", int(ts))
migrated += 1
for ch_idx in rs_data.get('muted_channels', []):
db.set_channel_muted(int(ch_idx), True)
read_status_file.rename(read_status_file.with_suffix('.json.bak'))
logger.info(f"Migrated {migrated} read status entries to DB")
except Exception as e:
logger.warning(f"Failed to migrate .read_status.json: {e}")
# v2: Initialize and start device manager
device_manager = DeviceManager(config, db, socketio)
app.device_manager = device_manager
@@ -203,17 +305,20 @@ def create_app():
runtime_config.set_device_name(dev_name, "device")
logger.info(f"Device name resolved: {dev_name}")
# Rename DB to match device name (mc-webui.db -> {name}.db)
_migrate_db_to_device_name(db, dev_name)
# Ensure device info is stored in current DB
pubkey = ''
if device_manager.self_info:
pubkey = device_manager.self_info.get('public_key', '')
db.set_device_info(
public_key=device_manager.self_info.get('public_key', ''),
public_key=pubkey,
name=dev_name,
self_info=json.dumps(device_manager.self_info, default=str)
)
# Rename DB to pubkey-based name (e.g. mc-webui.db -> mc_9cebbd27.db)
if pubkey:
_migrate_db_to_pubkey(db, pubkey)
# Auto-migrate v1 data if .msgs file exists and DB is empty
try:
from app.migrate_v1 import should_migrate, migrate_v1_data
@@ -225,6 +330,9 @@ def create_app():
except Exception as e:
logger.error(f"v1 migration failed: {e}")
# Clean up stale JSONL files (data is now in DB)
_cleanup_legacy_jsonl(Path(config.MC_CONFIG_DIR))
return
logger.warning("Timeout waiting for device connection")
@@ -235,7 +343,7 @@ def create_app():
schedule_daily_archiving()
init_retention_schedule(db=db)
logger.info(f"mc-webui v2 started — transport: {'TCP' if config.use_tcp else 'serial'}")
logger.info(f"mc-webui v2 started — transport: {config.transport_type}")
logger.info(f"Database: {db.db_path}")
return app
@@ -836,6 +944,43 @@ def _execute_console_command(args: list) -> str:
return result.get('message', 'Pending contacts flushed')
return f"Error: {result.get('error')}"
elif cmd == 'manual_add' and len(args) >= 2:
# Two variants:
# manual_add meshcore://contact/add?name=...&public_key=...&type=...
# manual_add <public_key> <type> <name with spaces>
arg1 = args[1]
parsed = parse_meshcore_uri(arg1)
if parsed:
result = device_manager.add_contact_manual(parsed['name'], parsed['public_key'], parsed['type'])
elif len(args) >= 4:
public_key = args[1]
try:
contact_type = int(args[2])
except ValueError:
return "Error: type must be integer (1=COM, 2=REP, 3=ROOM, 4=SENS)"
name = ' '.join(args[3:])
result = device_manager.add_contact_manual(name, public_key, contact_type)
else:
return (
"Usage:\n"
" manual_add <URI>\n"
" manual_add <public_key> <type> <name>\n\n"
"URI format: meshcore://contact/add?name=...&public_key=...&type=...\n"
"Types: 1=COM, 2=REP, 3=ROOM, 4=SENS"
)
if result.get('success'):
return result.get('message', 'Contact added')
return f"Error: {result.get('error')}"
elif cmd == 'manual_add':
return (
"Usage:\n"
" manual_add <URI>\n"
" manual_add <public_key> <type> <name>\n\n"
"URI format: meshcore://contact/add?name=...&public_key=...&type=...\n"
"Types: 1=COM, 2=REP, 3=ROOM, 4=SENS"
)
# ── Device management commands ───────────────────────────────
elif cmd == 'get' and len(args) >= 2:
@@ -853,7 +998,8 @@ def _execute_console_command(args: list) -> str:
" radio — radio parameters (freq, bw, sf, cr)\n"
" stats — device status/statistics\n"
" custom — all custom variables (JSON)\n"
" path_hash_mode — path hash mode"
" advert_loc_policy — location in adverts (0=none/1=share/2=prefs)\n"
" path_hash_mode — path hash mode (0/1/2)"
)
if result.get('success'):
data = result.get('data', {})
@@ -899,11 +1045,11 @@ def _execute_console_command(args: list) -> str:
" radio <freq,bw,sf,cr> — radio params\n"
" multi_acks <on/off> — multi-acks feature\n"
" manual_add_contacts <on/off> — manual contact approval\n"
" telemetry_mode_base <mode> — basic telemetry (all/selected/off)\n"
" telemetry_mode_loc <mode> — location telemetry\n"
" telemetry_mode_env <mode> — environment telemetry\n"
" advert_loc_policy <policy> — location in adverts\n"
" path_hash_mode <value> — path hash mode\n"
" telemetry_mode_base <0-2> — basic telemetry (0=off/1=selected/2=all)\n"
" telemetry_mode_loc <0-2> — location telemetry (0=off/1=selected/2=all)\n"
" telemetry_mode_env <0-2> — environment telemetry (0=off/1=selected/2=all)\n"
" advert_loc_policy <0-2> — location in adverts (0=none/1=share/2=prefs)\n"
" path_hash_mode <0-2> — path hash mode (0/1/2)\n"
" <custom_var> <value> — set custom variable"
)
@@ -1000,12 +1146,30 @@ def _execute_console_command(args: list) -> str:
data = result['data']
if not data:
return "No nodes discovered"
type_names = ["NONE", "COM", "REP", "ROOM", "SENS"]
lines = [f"Discovered nodes ({len(data)}):"]
for node in data:
if isinstance(node, dict):
name = node.get('adv_name', node.get('name', '?'))
pk = node.get('public_key', '')[:12]
lines.append(f" {name} ({pk}...)")
pk = node.get('pubkey', '')
# Try to resolve name from contacts
name = None
if pk and device_manager.mc:
try:
contact = device_manager.mc.get_contact_by_key_prefix(pk)
if contact:
name = contact.get('adv_name', '')
except Exception:
pass
if name:
label = f"{pk[:6]} {name}"
else:
label = pk[:16] or '?'
nt = node.get('node_type', 0)
type_str = type_names[nt] if nt < len(type_names) else f"t:{nt}"
snr_in = node.get('SNR_in', 0)
snr = node.get('SNR', 0)
rssi = node.get('RSSI', 0)
lines.append(f" {label:28} {type_str:>4} SNR: {snr_in:6.2f}->{snr:6.2f} RSSI: {rssi}")
else:
lines.append(f" {node}")
return "\n".join(lines)
@@ -1093,7 +1257,8 @@ def _execute_console_command(args: list) -> str:
" advert_path <name> — Get path from advert\n"
" share_contact <name> — Share contact with mesh\n"
" export_contact <name> — Export contact URI\n"
" import_contact <URI> — Import contact from URI\n"
" import_contact <URI> — Import contact from hex blob URI\n"
" manual_add <URI|params> — Add contact from mobile app URI or params\n"
" remove_contact <name> — Remove contact from device\n"
" change_flags <n> <f> — Change contact flags\n"
" pending_contacts — Show pending contacts\n"
+18 -9
View File
@@ -60,20 +60,17 @@ def recv_messages() -> Tuple[bool, str]:
return True, "Messages are received automatically via events"
def send_message(text: str, reply_to: Optional[str] = None, channel_index: int = 0) -> Tuple[bool, str]:
"""Send a message to a channel."""
def send_message(text: str, reply_to: Optional[str] = None, channel_index: int = 0) -> Dict:
"""Send a message to a channel. Returns result dict with id and timestamp."""
if reply_to:
text = f"@[{reply_to}] {text}"
try:
dm = _get_dm()
result = dm.send_channel_message(channel_index, text)
if result['success']:
return True, result.get('message', 'Message sent')
return False, result.get('error', 'Failed to send message')
return dm.send_channel_message(channel_index, text)
except Exception as e:
logger.error(f"send_message error: {e}")
return False, str(e)
return {'success': False, 'error': str(e)}
# =============================================================================
@@ -161,10 +158,22 @@ def _parse_last_advert(value) -> int:
def get_contacts_with_last_seen() -> Tuple[bool, Dict[str, Dict], str]:
"""Get contacts actually on the device firmware (from mc.contacts)."""
"""Get contacts actually on the device firmware (from mc.contacts).
Refreshes from device if contacts_dirty flag is set (e.g., after
receiving adverts that may carry updated names/paths).
"""
try:
dm = _get_dm()
if not dm.mc or not dm.mc.contacts:
if not dm.mc:
return True, {}, ""
# Refresh contacts from device if dirty (name changes, path updates, etc.)
if dm.mc.contacts_dirty:
dm.execute(dm.mc.ensure_contacts(follow=True))
dm._sync_contacts_to_db()
if not dm.mc.contacts:
return True, {}, ""
contacts_dict = {}
for pk, contact in dm.mc.contacts.items():
+53
View File
@@ -0,0 +1,53 @@
"""
MeshCore flood-scope (region) helpers.
Key derivation and name validation for the per-channel region-scope feature.
Kept free of Flask/DB imports so it can be unit-tested in isolation.
Firmware references:
- Key: SHA256('#' + name)[:16] (TransportKeyStore::getAutoKeyFor)
- Name rule: '-', '$', '#', digits, or any byte >= 'A' (RegionMap::is_name_char)
- Name length: fits in a 31-char field (30 chars + NUL terminator)
"""
import hashlib
from typing import Tuple
MAX_NAME_LEN = 30 # firmware NodePrefs.default_scope_name[31] = 30 chars + NUL
_ALLOWED_SINGLE_BYTES = (0x2d, 0x24, 0x23) # '-', '$', '#'
def is_valid_region_name(name: str) -> Tuple[bool, str]:
"""Validate a region name against the firmware's RegionMap::is_name_char rule.
Returns (ok, error_message). On success error_message is ''.
"""
if not isinstance(name, str) or not name:
return False, 'Name must be a non-empty string'
try:
encoded = name.encode('utf-8')
except UnicodeEncodeError:
return False, 'Name must be UTF-8 encodable'
if len(encoded) > MAX_NAME_LEN:
return False, f'Name too long (max {MAX_NAME_LEN} bytes)'
for b in encoded:
if b in _ALLOWED_SINGLE_BYTES:
continue
if 0x30 <= b <= 0x39: # digits
continue
if b >= 0x41: # any byte >= 'A'
continue
return False, f'Invalid character (byte 0x{b:02x})'
return True, ''
def derive_scope_key(name: str) -> bytes:
"""Derive the 16-byte scope key: SHA256('#' + name)[:16]."""
payload = name if name.startswith('#') else '#' + name
return hashlib.sha256(payload.encode('utf-8')).digest()[:16]
def derive_scope_key_hex(name: str) -> str:
"""Hex-encoded variant of derive_scope_key()."""
return derive_scope_key(name).hex()
+91 -195
View File
@@ -1,198 +1,108 @@
"""
Read Status Manager - Server-side storage for message read status
Read Status Manager - DB-backed storage for message read status
Manages the last seen timestamps for channels and DM conversations,
providing cross-device synchronization for unread message tracking.
All data is stored in the read_status table of the SQLite database.
"""
import json
import logging
import os
from pathlib import Path
from threading import Lock
from app.config import config
from flask import current_app
logger = logging.getLogger(__name__)
# Thread-safe lock for file operations
_status_lock = Lock()
# Path to read status file
READ_STATUS_FILE = Path(config.MC_CONFIG_DIR) / '.read_status.json'
def _get_default_status():
"""Get default read status structure"""
return {
'channels': {}, # {"0": timestamp, "1": timestamp, ...}
'dm': {}, # {"name_User1": timestamp, "pk_abc123": timestamp, ...}
'muted_channels': [] # [2, 5, 7] - channel indices with muted notifications
}
def _get_db():
"""Get database instance from Flask app context."""
return getattr(current_app, 'db', None)
def load_read_status():
"""
Load read status from disk.
"""Load read status from database.
Returns:
dict: Read status with 'channels' and 'dm' keys
dict: Read status with 'channels', 'dm', and 'muted_channels' keys
"""
with _status_lock:
try:
if not READ_STATUS_FILE.exists():
logger.info("Read status file does not exist, creating default")
return _get_default_status()
try:
db = _get_db()
rows = db.get_read_status()
with open(READ_STATUS_FILE, 'r', encoding='utf-8') as f:
status = json.load(f)
channels = {}
dm = {}
muted_channels = []
favorite_channels = []
# Validate structure
if not isinstance(status, dict):
logger.warning("Invalid read status structure, resetting")
return _get_default_status()
for key, row in rows.items():
if key.startswith('chan_'):
chan_idx = key[5:] # "chan_0" -> "0"
channels[chan_idx] = row['last_seen_ts']
if row.get('is_muted'):
try:
muted_channels.append(int(chan_idx))
except ValueError:
pass
if row.get('is_favorite'):
try:
favorite_channels.append(int(chan_idx))
except ValueError:
pass
elif key.startswith('dm_'):
conv_id = key[3:] # "dm_name_User1" -> "name_User1"
dm[conv_id] = row['last_seen_ts']
# Ensure all keys exist
if 'channels' not in status:
status['channels'] = {}
if 'dm' not in status:
status['dm'] = {}
if 'muted_channels' not in status:
status['muted_channels'] = []
return {
'channels': channels,
'dm': dm,
'muted_channels': muted_channels,
'favorite_channels': favorite_channels,
}
logger.debug(f"Loaded read status: {len(status['channels'])} channels, {len(status['dm'])} DM conversations")
return status
except json.JSONDecodeError as e:
logger.error(f"Failed to parse read status file: {e}")
return _get_default_status()
except Exception as e:
logger.error(f"Error loading read status: {e}")
return _get_default_status()
except Exception as e:
logger.error(f"Error loading read status: {e}")
return {'channels': {}, 'dm': {}, 'muted_channels': [], 'favorite_channels': []}
def save_read_status(status):
"""
Save read status to disk.
Args:
status (dict): Read status with 'channels' and 'dm' keys
Returns:
bool: True if successful, False otherwise
"""
with _status_lock:
try:
# Ensure directory exists
READ_STATUS_FILE.parent.mkdir(parents=True, exist_ok=True)
# Write atomically (write to temp file, then rename)
temp_file = READ_STATUS_FILE.with_suffix('.tmp')
with open(temp_file, 'w', encoding='utf-8') as f:
json.dump(status, f, indent=2)
# Atomic rename
temp_file.replace(READ_STATUS_FILE)
logger.debug(f"Saved read status: {len(status['channels'])} channels, {len(status['dm'])} DM conversations")
return True
except Exception as e:
logger.error(f"Error saving read status: {e}")
return False
"""No-op — data is written per-operation via mark_* functions."""
return True
def mark_channel_read(channel_idx, timestamp):
"""
Mark a channel as read up to a specific timestamp.
Args:
channel_idx (int or str): Channel index (will be converted to string)
timestamp (int or float): Unix timestamp of last read message
Returns:
bool: True if successful, False otherwise
"""
"""Mark a channel as read up to a specific timestamp."""
try:
# Load current status
status = load_read_status()
# Update channel timestamp (ensure key is string for JSON compatibility)
channel_key = str(channel_idx)
status['channels'][channel_key] = int(timestamp)
# Save updated status
success = save_read_status(status)
if success:
logger.debug(f"Marked channel {channel_idx} as read at timestamp {timestamp}")
return success
db = _get_db()
db.mark_read(f"chan_{channel_idx}", int(timestamp))
logger.debug(f"Marked channel {channel_idx} as read at timestamp {timestamp}")
return True
except Exception as e:
logger.error(f"Error marking channel {channel_idx} as read: {e}")
return False
def mark_dm_read(conversation_id, timestamp):
"""
Mark a DM conversation as read up to a specific timestamp.
Args:
conversation_id (str): Conversation identifier (e.g., "name_User1" or "pk_abc123")
timestamp (int or float): Unix timestamp of last read message
Returns:
bool: True if successful, False otherwise
"""
"""Mark a DM conversation as read up to a specific timestamp."""
try:
# Load current status
status = load_read_status()
# Update DM timestamp
status['dm'][conversation_id] = int(timestamp)
# Save updated status
success = save_read_status(status)
if success:
logger.debug(f"Marked DM conversation {conversation_id} as read at timestamp {timestamp}")
return success
db = _get_db()
db.mark_read(f"dm_{conversation_id}", int(timestamp))
logger.debug(f"Marked DM conversation {conversation_id} as read at timestamp {timestamp}")
return True
except Exception as e:
logger.error(f"Error marking DM conversation {conversation_id} as read: {e}")
return False
def get_channel_last_seen(channel_idx):
"""
Get last seen timestamp for a specific channel.
Args:
channel_idx (int or str): Channel index
Returns:
int: Unix timestamp, or 0 if never seen
"""
"""Get last seen timestamp for a specific channel."""
try:
status = load_read_status()
channel_key = str(channel_idx)
return status['channels'].get(channel_key, 0)
return status['channels'].get(str(channel_idx), 0)
except Exception as e:
logger.error(f"Error getting last seen for channel {channel_idx}: {e}")
return 0
def get_dm_last_seen(conversation_id):
"""
Get last seen timestamp for a specific DM conversation.
Args:
conversation_id (str): Conversation identifier
Returns:
int: Unix timestamp, or 0 if never seen
"""
"""Get last seen timestamp for a specific DM conversation."""
try:
status = load_read_status()
return status['dm'].get(conversation_id, 0)
@@ -202,75 +112,61 @@ def get_dm_last_seen(conversation_id):
def get_muted_channels():
"""
Get list of muted channel indices.
Returns:
list: List of muted channel indices (integers)
"""
"""Get list of muted channel indices."""
try:
status = load_read_status()
return status.get('muted_channels', [])
db = _get_db()
return db.get_muted_channels()
except Exception as e:
logger.error(f"Error getting muted channels: {e}")
return []
def set_channel_muted(channel_idx, muted):
"""
Set mute state for a channel.
Args:
channel_idx (int): Channel index
muted (bool): True to mute, False to unmute
Returns:
bool: True if successful
"""
"""Set mute state for a channel."""
try:
status = load_read_status()
muted_list = status.get('muted_channels', [])
channel_idx = int(channel_idx)
if muted and channel_idx not in muted_list:
muted_list.append(channel_idx)
elif not muted and channel_idx in muted_list:
muted_list.remove(channel_idx)
status['muted_channels'] = muted_list
success = save_read_status(status)
if success:
logger.info(f"Channel {channel_idx} {'muted' if muted else 'unmuted'}")
return success
db = _get_db()
db.set_channel_muted(int(channel_idx), muted)
logger.info(f"Channel {channel_idx} {'muted' if muted else 'unmuted'}")
return True
except Exception as e:
logger.error(f"Error setting mute for channel {channel_idx}: {e}")
return False
def get_favorite_channels():
"""Get list of favorite channel indices."""
try:
db = _get_db()
return db.get_favorite_channels()
except Exception as e:
logger.error(f"Error getting favorite channels: {e}")
return []
def set_channel_favorite(channel_idx, favorite):
"""Set favorite state for a channel."""
try:
db = _get_db()
db.set_channel_favorite(int(channel_idx), favorite)
logger.info(f"Channel {channel_idx} {'favorited' if favorite else 'unfavorited'}")
return True
except Exception as e:
logger.error(f"Error setting favorite for channel {channel_idx}: {e}")
return False
def mark_all_channels_read(channel_timestamps):
"""
Mark all channels as read in bulk.
"""Mark all channels as read in bulk.
Args:
channel_timestamps (dict): {"0": timestamp, "1": timestamp, ...}
Returns:
bool: True if successful
"""
try:
status = load_read_status()
db = _get_db()
for channel_key, timestamp in channel_timestamps.items():
status['channels'][str(channel_key)] = int(timestamp)
success = save_read_status(status)
if success:
logger.info(f"Marked {len(channel_timestamps)} channels as read")
return success
db.mark_read(f"chan_{channel_key}", int(timestamp))
logger.info(f"Marked {len(channel_timestamps)} channels as read")
return True
except Exception as e:
logger.error(f"Error marking all channels as read: {e}")
return False
+859 -98
View File
File diff suppressed because it is too large Load Diff
+19 -2
View File
@@ -50,6 +50,17 @@ def contact_management():
)
@views_bp.route('/contacts/add')
def contact_add():
"""
Add Contact page - URI paste, QR scan, manual fields.
"""
return render_template(
'contacts-add.html',
device_name=runtime_config.get_device_name()
)
@views_bp.route('/contacts/pending')
def contact_pending_list():
"""
@@ -93,7 +104,13 @@ def logs():
@views_bp.route('/health')
def health():
"""Health check endpoint for monitoring.
Returns 503 when BLE reconnection has permanently failed so Docker's
healthcheck triggers a container restart (which clears all BLE state).
"""
Health check endpoint for monitoring.
"""
from flask import current_app
dm = getattr(current_app, 'device_manager', None)
if dm and getattr(dm, '_ble_permanently_failed', False):
return 'BLE connection permanently failed', 503
return 'OK', 200
+22 -2
View File
@@ -43,6 +43,23 @@ CREATE TABLE IF NOT EXISTS channels (
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Region registry (user-curated MeshCore flood scopes)
CREATE TABLE IF NOT EXISTS regions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, -- firmware-safe name, e.g. 'pl-ma'
key_hex TEXT NOT NULL, -- 32 hex chars = 16-byte scope key
is_default INTEGER NOT NULL DEFAULT 0, -- mirrors firmware CMD_GET_DEFAULT_FLOOD_SCOPE
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Per-channel region mapping (absent row = no override; firmware default applies)
CREATE TABLE IF NOT EXISTS channel_scopes (
channel_idx INTEGER PRIMARY KEY,
region_id INTEGER NOT NULL REFERENCES regions(id) ON DELETE CASCADE,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Channel messages (replaces CHAN/SENT_CHAN from .msgs)
CREATE TABLE IF NOT EXISTS channel_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -101,6 +118,7 @@ CREATE TABLE IF NOT EXISTS echoes (
received_at TEXT NOT NULL DEFAULT (datetime('now')),
direction TEXT DEFAULT 'incoming', -- 'sent' or 'incoming'
cm_id INTEGER, -- FK to channel_messages (nullable)
hash_size INTEGER NOT NULL DEFAULT 1, -- bytes per hop hash: 1, 2, or 3
FOREIGN KEY (cm_id) REFERENCES channel_messages(id) ON DELETE SET NULL
);
@@ -146,18 +164,19 @@ CREATE TABLE IF NOT EXISTS read_status (
key TEXT PRIMARY KEY, -- 'chan_0', 'dm_<pubkey>', etc.
last_seen_ts INTEGER DEFAULT 0, -- unix timestamp
is_muted INTEGER DEFAULT 0, -- 1 = muted (channels only)
is_favorite INTEGER DEFAULT 0, -- 1 = favorite (channels only)
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Ignored contacts (adverts cached but not pending/auto-added)
CREATE TABLE IF NOT EXISTS ignored_contacts (
public_key TEXT PRIMARY KEY REFERENCES contacts(public_key),
public_key TEXT PRIMARY KEY REFERENCES contacts(public_key) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Blocked contacts (ignored + messages hidden from display)
CREATE TABLE IF NOT EXISTS blocked_contacts (
public_key TEXT PRIMARY KEY REFERENCES contacts(public_key),
public_key TEXT PRIMARY KEY REFERENCES contacts(public_key) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
@@ -187,6 +206,7 @@ CREATE INDEX IF NOT EXISTS idx_echoes_pkt ON echoes(pkt_payload);
CREATE INDEX IF NOT EXISTS idx_adv_pubkey ON advertisements(public_key, timestamp);
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
CREATE INDEX IF NOT EXISTS idx_cp_contact ON contact_paths(contact_pubkey, sort_order);
CREATE INDEX IF NOT EXISTS idx_regions_default ON regions(is_default) WHERE is_default = 1;
-- ============================================================
-- Full-Text Search (FTS5)
+870 -124
View File
File diff suppressed because it is too large Load Diff
+615
View File
@@ -0,0 +1,615 @@
/* =============================================================================
mc-webui Theme System
Defines CSS custom properties for light/dark themes.
Bootstrap 5.3 data-bs-theme handles most component styling;
these variables cover custom app-specific elements.
============================================================================= */
/* =============================================================================
Light Theme (default)
============================================================================= */
:root {
/* Backgrounds */
--bg-body: #ffffff;
--bg-surface: #f8f9fa;
--bg-surface-alt: #f0f0f0;
--bg-hover: #e9ecef;
--bg-active: #e7f1ff;
--bg-messages: #ffffff;
--bg-dm-messages: #fafafa;
/* Text */
--text-primary: #212529;
--text-secondary: #495057;
--text-muted: #6c757d;
--text-meta: #adb5bd;
--dm-meta-color: #8b939b;
/* Borders */
--border-color: #dee2e6;
--border-light: #f0f0f0;
/* Messages */
--msg-own-bg: #e7f1ff;
--msg-other-bg: #f8f9fa;
--msg-border: #dee2e6;
--msg-own-border: #b8daff;
/* Sender */
--sender-color: #0d6efd;
--sender-own-color: #084298;
/* Navbar */
--navbar-bg: #0d6efd;
--navbar-border: transparent;
/* Scrollbar */
--scrollbar-track: #f1f1f1;
--scrollbar-thumb: #888;
--scrollbar-thumb-hover: #555;
--scrollbar-thumb-light: #ccc;
--scrollbar-thumb-light-hover: #aaa;
/* Filter */
--filter-bg: #ffffff;
--filter-highlight: #fff3cd;
--filter-input-border: #ced4da;
--filter-btn-me-bg: #e7f1ff;
--filter-btn-me-color: #0d6efd;
--filter-btn-me-hover: #cfe2ff;
--filter-btn-clear-bg: #f8f9fa;
--filter-btn-clear-color: #6c757d;
--filter-btn-clear-hover: #e9ecef;
/* Popup / Dropdown */
--popup-bg: #ffffff;
--popup-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
/* Quote */
--quote-color: #6c757d;
--quote-bg: rgba(108, 117, 125, 0.1);
--quote-border: #6c757d;
--quote-own-color: #495057;
--quote-own-bg: rgba(8, 66, 152, 0.1);
--quote-own-border: #084298;
/* Mention badge */
--mention-bg: #0d6efd;
--mention-own-bg: #084298;
/* Links */
--link-color: #0d6efd;
--link-hover: #0a58ca;
--link-own-color: #084298;
--link-own-hover: #052c65;
/* Channel link */
--channel-link-bg: #198754;
--channel-link-hover: #157347;
--channel-link-own-bg: #0f5132;
--channel-link-own-hover: #0d4429;
/* Echo badge */
--echo-color: #198754;
--echo-bg: rgba(25, 135, 84, 0.1);
/* Search */
--search-mark-bg: #fff3cd;
/* Offcanvas menu */
--offcanvas-item-border: #dee2e6;
--offcanvas-item-hover: #f8f9fa;
--offcanvas-icon-color: #0d6efd;
/* FAB */
--fab-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
--fab-shadow-hover: 0 6px 12px rgba(0, 0, 0, 0.4);
/* Conversation list */
--conversation-border: #dee2e6;
--conversation-hover: #f8f9fa;
--conversation-unread: #e7f1ff;
/* Map filter badges */
--map-badge-inactive-bg: white;
/* Mention autocomplete */
--mention-item-highlight: #e7f1ff;
--mention-item-border: #f0f0f0;
/* Image border */
--image-border: #dee2e6;
/* Actions border */
--actions-border: rgba(0, 0, 0, 0.1);
/* Cards */
--card-bg: #ffffff;
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
--card-shadow-hover: 0 2px 8px rgba(0, 0, 0, 0.15);
/* Info badge */
--info-badge-bg: #e7f3ff;
--info-badge-color: #0c5460;
/* Contact key clickable */
--key-hover-color: #0d6efd;
--key-hover-bg: #e7f1ff;
--key-copied-color: #198754;
--key-copied-bg: #d1e7dd;
/* Path items (DM) */
--path-item-bg: #ffffff;
--path-item-border: #dee2e6;
--path-item-primary-bg: #f0f7ff;
--path-item-primary-border: #0d6efd;
/* DM contact dropdown */
--dropdown-bg: #ffffff;
--dropdown-separator-bg: #f8f9fa;
--dropdown-item-hover: #e9ecef;
}
/* =============================================================================
Dark Theme
Inspired by mc-webui demo landing page (https://mc-webui.marwoj.net/)
Color palette: deep navy backgrounds, slate surfaces, soft blue accents
============================================================================= */
[data-theme="dark"] {
/* Override Bootstrap 5.3 dark mode variables for our custom palette */
--bs-body-bg: #0f172a;
--bs-body-color: #f8fafc;
--bs-border-color: #334155;
--bs-tertiary-bg: #1e293b;
--bs-secondary-bg: #162032;
/* Backgrounds */
--bg-body: #0f172a;
--bg-surface: #1e293b;
--bg-surface-alt: #162032;
--bg-hover: #2d3a4e;
--bg-active: #1e3a5f;
--bg-messages: #0f172a;
--bg-dm-messages: #131c2e;
/* Text */
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--text-meta: #475569;
--dm-meta-color: #5c6d82;
/* Borders */
--border-color: #334155;
--border-light: #1e293b;
/* Messages */
--msg-own-bg: #1e3a5f;
--msg-other-bg: #1e293b;
--msg-border: #334155;
--msg-own-border: #2563eb;
/* Sender */
--sender-color: #60a5fa;
--sender-own-color: #93c5fd;
/* Navbar */
--navbar-bg: #1e293b;
--navbar-border: #334155;
/* Scrollbar */
--scrollbar-track: #1e293b;
--scrollbar-thumb: #475569;
--scrollbar-thumb-hover: #64748b;
--scrollbar-thumb-light: #334155;
--scrollbar-thumb-light-hover: #475569;
/* Filter */
--filter-bg: #1e293b;
--filter-highlight: rgba(251, 191, 36, 0.2);
--filter-input-border: #334155;
--filter-btn-me-bg: #1e3a5f;
--filter-btn-me-color: #60a5fa;
--filter-btn-me-hover: #264a6f;
--filter-btn-clear-bg: #1e293b;
--filter-btn-clear-color: #94a3b8;
--filter-btn-clear-hover: #2d3a4e;
/* Popup / Dropdown */
--popup-bg: #1e293b;
--popup-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.4);
/* Quote */
--quote-color: #94a3b8;
--quote-bg: rgba(148, 163, 184, 0.1);
--quote-border: #64748b;
--quote-own-color: #94a3b8;
--quote-own-bg: rgba(37, 99, 235, 0.15);
--quote-own-border: #2563eb;
/* Mention badge */
--mention-bg: #2563eb;
--mention-own-bg: #1d4ed8;
/* Links */
--link-color: #60a5fa;
--link-hover: #93c5fd;
--link-own-color: #93c5fd;
--link-own-hover: #bfdbfe;
/* Channel link */
--channel-link-bg: #059669;
--channel-link-hover: #10b981;
--channel-link-own-bg: #047857;
--channel-link-own-hover: #059669;
/* Echo badge */
--echo-color: #10b981;
--echo-bg: rgba(16, 185, 129, 0.15);
/* Search */
--search-mark-bg: rgba(251, 191, 36, 0.3);
/* Offcanvas menu */
--offcanvas-item-border: #334155;
--offcanvas-item-hover: #253347;
--offcanvas-icon-color: #60a5fa;
/* FAB */
--fab-shadow: 0 4px 8px rgba(0, 0, 0, 0.5);
--fab-shadow-hover: 0 6px 12px rgba(0, 0, 0, 0.6);
/* Conversation list */
--conversation-border: #334155;
--conversation-hover: #253347;
--conversation-unread: #1e3a5f;
/* Map filter badges */
--map-badge-inactive-bg: #1e293b;
/* Mention autocomplete */
--mention-item-highlight: #1e3a5f;
--mention-item-border: #334155;
/* Image border */
--image-border: #334155;
/* Actions border */
--actions-border: rgba(255, 255, 255, 0.1);
/* Cards */
--card-bg: #1e293b;
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
--card-shadow-hover: 0 2px 8px rgba(0, 0, 0, 0.4);
/* Info badge */
--info-badge-bg: rgba(37, 99, 235, 0.15);
--info-badge-color: #60a5fa;
/* Contact key clickable */
--key-hover-color: #60a5fa;
--key-hover-bg: #1e3a5f;
--key-copied-color: #10b981;
--key-copied-bg: rgba(16, 185, 129, 0.15);
/* Path items (DM) */
--path-item-bg: #1e293b;
--path-item-border: #334155;
--path-item-primary-bg: #1e3a5f;
--path-item-primary-border: #2563eb;
/* DM contact dropdown */
--dropdown-bg: #1e293b;
--dropdown-separator-bg: #162032;
--dropdown-item-hover: #2d3a4e;
}
/* =============================================================================
Dark Theme - Bootstrap Component Overrides
Bootstrap 5.3 data-bs-theme="dark" handles most defaults; these overrides
customize colors to match our deep navy palette.
============================================================================= */
/* Navbar */
[data-theme="dark"] .navbar.bg-primary {
background-color: var(--navbar-bg) !important;
border-bottom: 1px solid var(--navbar-border);
}
[data-theme="dark"] .navbar .btn-outline-light {
border-color: #475569;
color: #94a3b8;
}
[data-theme="dark"] .navbar .btn-outline-light:hover {
background-color: #334155;
border-color: #64748b;
color: #f8fafc;
}
/* Form controls */
[data-theme="dark"] .form-control,
[data-theme="dark"] .form-select {
background-color: var(--bg-body);
color: var(--text-primary);
border-color: var(--border-color);
}
[data-theme="dark"] .form-control:focus,
[data-theme="dark"] .form-select:focus {
background-color: var(--bg-body);
color: var(--text-primary);
border-color: #3b82f6;
box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.25);
}
[data-theme="dark"] .form-control::placeholder {
color: var(--text-muted);
}
/* Modal */
[data-theme="dark"] .modal-content {
background-color: var(--bg-surface);
color: var(--text-primary);
border-color: var(--border-color);
}
[data-theme="dark"] .modal-header {
border-bottom-color: var(--border-color);
}
[data-theme="dark"] .modal-footer {
border-top-color: var(--border-color);
}
[data-theme="dark"] .btn-close {
filter: invert(1) grayscale(100%) brightness(200%);
}
/* Offcanvas */
[data-theme="dark"] .offcanvas {
background-color: var(--bg-surface);
color: var(--text-primary);
}
[data-theme="dark"] .offcanvas-header {
border-bottom-color: var(--border-color);
}
/* List group */
[data-theme="dark"] .list-group-item {
background-color: transparent;
color: var(--text-primary);
border-color: var(--border-color);
}
[data-theme="dark"] .list-group-item-action:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
}
/* Nav tabs */
[data-theme="dark"] .nav-tabs {
border-bottom-color: var(--border-color);
}
[data-theme="dark"] .nav-tabs .nav-link {
color: var(--text-muted);
}
[data-theme="dark"] .nav-tabs .nav-link:hover {
border-color: var(--border-color);
color: var(--text-secondary);
}
[data-theme="dark"] .nav-tabs .nav-link.active {
background-color: var(--bg-surface);
color: var(--text-primary);
border-color: var(--border-color) var(--border-color) var(--bg-surface);
}
/* Tables */
[data-theme="dark"] .table {
color: var(--text-primary);
border-color: var(--border-color);
}
/* Alerts */
[data-theme="dark"] .alert-info {
background-color: rgba(59, 130, 246, 0.1);
color: #60a5fa;
border-color: rgba(59, 130, 246, 0.2);
}
[data-theme="dark"] .alert-light {
background-color: var(--bg-surface-alt);
color: var(--text-secondary);
border-color: var(--border-color);
}
/* Card (Bootstrap) */
[data-theme="dark"] .card {
background-color: var(--bg-surface);
border-color: var(--border-color);
color: var(--text-primary);
}
/* Badge overrides for better dark mode contrast */
[data-theme="dark"] .badge.bg-secondary {
background-color: #475569 !important;
}
/* Text utilities */
[data-theme="dark"] .text-muted {
color: var(--text-muted) !important;
}
[data-theme="dark"] .text-dark {
color: var(--text-primary) !important;
}
[data-theme="dark"] .border-bottom {
border-bottom-color: var(--border-color) !important;
}
[data-theme="dark"] .border-top {
border-top-color: var(--border-color) !important;
}
/* bg-light override */
[data-theme="dark"] .bg-light {
background-color: var(--bg-surface-alt) !important;
}
/* Toast */
[data-theme="dark"] .toast {
background-color: var(--bg-surface);
color: var(--text-primary);
border-color: var(--border-color);
}
[data-theme="dark"] .toast-header {
background-color: var(--bg-surface-alt);
color: var(--text-primary);
border-bottom-color: var(--border-color);
}
/* Progress bar */
[data-theme="dark"] .progress {
background-color: var(--bg-surface-alt);
}
/* Tooltip-like popups */
[data-theme="dark"] .dm-delivery-popup,
[data-theme="dark"] .path-popup {
background-color: #475569;
color: #f8fafc;
}
/* Form check / switch */
[data-theme="dark"] .form-check-input {
background-color: var(--bg-surface-alt);
border-color: var(--border-color);
}
[data-theme="dark"] .form-check-input:checked {
background-color: #3b82f6;
border-color: #3b82f6;
}
/* Input group */
[data-theme="dark"] .input-group-text {
background-color: var(--bg-surface-alt);
color: var(--text-secondary);
border-color: var(--border-color);
}
/* Accordion (if used) */
[data-theme="dark"] .accordion-item {
background-color: var(--bg-surface);
border-color: var(--border-color);
}
/* Dropdown menu (Bootstrap) */
[data-theme="dark"] .dropdown-menu {
background-color: var(--bg-surface);
border-color: var(--border-color);
}
[data-theme="dark"] .dropdown-item {
color: var(--text-primary);
}
[data-theme="dark"] .dropdown-item:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
}
/* Spinner */
[data-theme="dark"] .spinner-border {
color: #3b82f6;
}
/* Status bar (bottom) */
[data-theme="dark"] .border-top {
border-color: var(--border-color) !important;
}
/* QR code container - keep white bg for readability */
[data-theme="dark"] .qr-code-container,
[data-theme="dark"] #shareChannelQR,
[data-theme="dark"] #deviceShareContent .text-center img,
[data-theme="dark"] #deviceShareContent canvas {
background-color: #ffffff;
padding: 8px;
border-radius: 0.5rem;
}
/* Emoji picker dark mode */
[data-theme="dark"] emoji-picker {
--background: #1e293b;
--border-color: #334155;
--indicator-color: #3b82f6;
--input-border-color: #334155;
--input-font-color: #f8fafc;
--input-placeholder-color: #64748b;
--outline-color: #3b82f6;
--category-font-color: #94a3b8;
--button-active-background: #334155;
--button-hover-background: #2d3a4e;
}
/* =============================================================================
Theme Switcher UI
============================================================================= */
.theme-option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border: 2px solid var(--border-color);
border-radius: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
background-color: var(--card-bg);
}
.theme-option:hover {
border-color: #3b82f6;
}
.theme-option.active {
border-color: #3b82f6;
background-color: var(--bg-active);
}
.theme-option-preview {
width: 40px;
height: 40px;
border-radius: 0.5rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
}
.theme-option-preview.light {
background: linear-gradient(135deg, #ffffff 50%, #e9ecef 50%);
border: 1px solid #dee2e6;
}
.theme-option-preview.dark {
background: linear-gradient(135deg, #1e293b 50%, #0f172a 50%);
border: 1px solid #334155;
}
.theme-option-label {
font-weight: 500;
}
.theme-option-desc {
font-size: 0.8rem;
color: var(--text-muted);
}
+1624 -139
View File
File diff suppressed because it is too large Load Diff
+375 -99
View File
@@ -10,6 +10,44 @@
* - Mobile-first design
*/
// --- UI settings bootstrap (for standalone contact pages that don't load app.js) ---
(function initContactsUiSettings() {
const TOAST_POSITION_CLASSES = {
'top-left': ['top-0', 'start-0'],
'top-right': ['top-0', 'end-0'],
'bottom-left': ['bottom-0', 'start-0'],
'bottom-right': ['bottom-0', 'end-0'],
'center': ['top-50', 'start-50', 'translate-middle']
};
const ALL_CLASSES = ['top-0', 'top-50', 'start-0', 'start-50', 'bottom-0', 'end-0', 'translate-middle'];
function apply(position) {
const classes = TOAST_POSITION_CLASSES[position] || TOAST_POSITION_CLASSES['top-left'];
document.querySelectorAll('[data-toast-container]').forEach(el => {
ALL_CLASSES.forEach(c => el.classList.remove(c));
classes.forEach(c => el.classList.add(c));
});
}
document.addEventListener('DOMContentLoaded', async () => {
// If app.js is on the page, it owns settings loading and will update all
// [data-toast-container] elements (including ours) once its fetch resolves.
if (typeof window.applyToastPosition === 'function') return;
try {
const resp = await fetch('/api/ui/settings');
if (resp.ok) {
const data = await resp.json();
window.uiSettingsCache = data;
apply(data.toast_position || 'top-left');
}
} catch (e) {
console.error('Failed to load UI settings:', e);
}
});
})();
// =============================================================================
// Global Navigation Helper
// =============================================================================
@@ -39,7 +77,6 @@ window.navigateTo = function(url) {
// =============================================================================
let currentPage = null; // 'manage', 'pending', 'existing'
let manualApprovalEnabled = false;
let pendingContacts = [];
let filteredPendingContacts = []; // Filtered pending contacts (for pending page filtering)
let existingContacts = [];
@@ -140,6 +177,8 @@ function detectCurrentPage() {
currentPage = 'pending';
} else if (document.getElementById('existingPageContent')) {
currentPage = 'existing';
} else if (document.getElementById('addPageContent')) {
currentPage = 'add';
}
console.log('Current page:', currentPage);
}
@@ -155,6 +194,9 @@ function initializePage() {
case 'existing':
initExistingPage();
break;
case 'add':
initAddPage();
break;
default:
console.warn('Unknown page type');
}
@@ -167,9 +209,6 @@ function initializePage() {
function initManagePage() {
console.log('Initializing Management page...');
// Load settings for manual approval toggle
loadSettings();
// Load contact counts for badges
loadContactCounts();
@@ -181,12 +220,6 @@ function initManagePage() {
}
function attachManageEventListeners() {
// Manual approval toggle
const approvalSwitch = document.getElementById('manualApprovalSwitch');
if (approvalSwitch) {
approvalSwitch.addEventListener('change', handleApprovalToggle);
}
// Cleanup preview button
const cleanupPreviewBtn = document.getElementById('cleanupPreviewBtn');
if (cleanupPreviewBtn) {
@@ -258,15 +291,20 @@ async function loadContactCounts() {
pendingBadge.classList.remove('spinner-border', 'spinner-border-sm');
}
// Fetch existing count
const existingResp = await fetch('/api/contacts/detailed');
// Fetch existing count (device + cached in parallel)
const [existingResp, cachedResp] = await Promise.all([
fetch('/api/contacts/detailed'),
fetch('/api/contacts/cached?format=count')
]);
const existingData = await existingResp.json();
const cachedData = await cachedResp.json();
const existingBadge = document.getElementById('existingBadge');
if (existingBadge && existingData.success) {
const count = existingData.count || 0;
const limit = existingData.limit || 350;
existingBadge.textContent = `${count} / ${limit}`;
const totalKnown = cachedData.success ? (cachedData.count || 0) : count;
existingBadge.innerHTML = `${totalKnown} (<i class="bi bi-cpu"></i> ${count}/${limit})`;
existingBadge.classList.remove('spinner-border', 'spinner-border-sm');
// Apply counter color coding
@@ -897,82 +935,6 @@ function attachExistingEventListeners() {
}
}
// =============================================================================
// Settings Management (shared)
// =============================================================================
async function loadSettings() {
try {
const response = await fetch('/api/device/settings');
const data = await response.json();
if (data.success) {
manualApprovalEnabled = data.settings.manual_add_contacts || false;
updateApprovalUI(manualApprovalEnabled);
} else {
console.error('Failed to load settings:', data.error);
showToast('Failed to load settings', 'danger');
}
} catch (error) {
console.error('Error loading settings:', error);
showToast('Network error loading settings', 'danger');
}
}
async function handleApprovalToggle(event) {
const enabled = event.target.checked;
try {
const response = await fetch('/api/device/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
manual_add_contacts: enabled
})
});
const data = await response.json();
if (data.success) {
manualApprovalEnabled = enabled;
updateApprovalUI(enabled);
showToast(
enabled ? 'Manual approval enabled' : 'Manual approval disabled',
'success'
);
} else {
console.error('Failed to update setting:', data.error);
showToast('Failed to update setting: ' + data.error, 'danger');
// Revert toggle on failure
event.target.checked = !enabled;
}
} catch (error) {
console.error('Error updating setting:', error);
showToast('Network error updating setting', 'danger');
// Revert toggle on failure
event.target.checked = !enabled;
}
}
function updateApprovalUI(enabled) {
const switchEl = document.getElementById('manualApprovalSwitch');
const labelEl = document.getElementById('switchLabel');
if (switchEl) {
switchEl.checked = enabled;
}
if (labelEl) {
labelEl.textContent = enabled
? 'Manual approval enabled'
: 'Automatic approval (default)';
}
}
// =============================================================================
// Protected Contacts Management
// =============================================================================
@@ -1773,10 +1735,15 @@ function showToast(message, type = 'info') {
toastEl.classList.add('bg-info', 'text-white');
}
// Show toast
// Show toast (honors ui_settings: timeout + no-autoclose; position handled by toast-container classes)
const cfg = window.uiSettingsCache || {};
const noAutoclose = !!cfg.toast_no_autoclose;
const timeoutSec = parseFloat(cfg.toast_timeout_sec);
const delay = isFinite(timeoutSec) && timeoutSec > 0 ? Math.round(timeoutSec * 1000) : 2000;
const toast = new bootstrap.Toast(toastEl, {
autohide: true,
delay: 1500
autohide: !noAutoclose,
delay: delay
});
toast.show();
}
@@ -1867,11 +1834,8 @@ function updateCounter(count, limit, totalKnown) {
const counterEl = document.getElementById('contactsCounter');
if (!counterEl) return;
let text = `${count} / ${limit}`;
if (totalKnown && totalKnown > count) {
text += ` (${totalKnown} cached)`;
}
counterEl.textContent = text;
const total = totalKnown || count;
counterEl.innerHTML = `${total} (<i class="bi bi-cpu"></i> ${count}/${limit})`;
counterEl.style.display = 'inline-block';
// Remove all counter classes
@@ -2290,7 +2254,7 @@ function createExistingContactCard(contact, index) {
actionsDiv.appendChild(mapBtn);
}
// Protect & Delete buttons (only for device contacts)
// Protect, Move to cache & Delete buttons (only for device contacts)
if (contact.on_device !== false) {
const protectBtn = document.createElement('button');
protectBtn.className = isProtected ? 'btn btn-sm btn-warning' : 'btn btn-sm btn-outline-warning';
@@ -2300,6 +2264,17 @@ function createExistingContactCard(contact, index) {
protectBtn.onclick = () => toggleContactProtection(contact.public_key, protectBtn);
actionsDiv.appendChild(protectBtn);
const moveToCacheBtn = document.createElement('button');
moveToCacheBtn.className = 'btn btn-sm btn-outline-info';
moveToCacheBtn.innerHTML = '<i class="bi bi-cloud-arrow-down"></i> <span class="btn-label">To cache</span>';
moveToCacheBtn.title = 'Remove from device, keep in cache';
moveToCacheBtn.onclick = () => moveContactToCache(contact);
moveToCacheBtn.disabled = isProtected;
if (isProtected) {
moveToCacheBtn.title = 'Cannot move protected contact';
}
actionsDiv.appendChild(moveToCacheBtn);
const deleteBtn = document.createElement('button');
deleteBtn.className = 'btn btn-sm btn-outline-danger';
deleteBtn.innerHTML = '<i class="bi bi-trash"></i> <span class="btn-label">Delete</span>';
@@ -2311,8 +2286,15 @@ function createExistingContactCard(contact, index) {
actionsDiv.appendChild(deleteBtn);
}
// Delete button for cache-only contacts
// Push to device & Delete buttons for cache-only contacts
if (contact.on_device === false) {
const pushToDeviceBtn = document.createElement('button');
pushToDeviceBtn.className = 'btn btn-sm btn-outline-success';
pushToDeviceBtn.innerHTML = '<i class="bi bi-cpu"></i> <span class="btn-label">To device</span>';
pushToDeviceBtn.title = 'Add this contact to the device';
pushToDeviceBtn.onclick = () => pushContactToDevice(contact);
actionsDiv.appendChild(pushToDeviceBtn);
const deleteCacheBtn = document.createElement('button');
deleteCacheBtn.className = 'btn btn-sm btn-outline-danger';
deleteCacheBtn.innerHTML = '<i class="bi bi-trash"></i> <span class="btn-label">Delete</span>';
@@ -2493,3 +2475,297 @@ async function confirmDelete() {
contactToDelete = null;
}
}
// =============================================================================
// Push to Device / Move to Cache
// =============================================================================
async function pushContactToDevice(contact) {
if (!confirm(`Push "${contact.name}" to device?`)) return;
try {
const response = await fetch(`/api/contacts/${contact.public_key}/push-to-device`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (data.success) {
showToast(data.message || `${contact.name} pushed to device`, 'success');
setTimeout(() => loadExistingContacts(), 500);
} else {
showToast(data.error || 'Failed to push contact', 'danger');
}
} catch (error) {
showToast('Network error: ' + error.message, 'danger');
}
}
async function moveContactToCache(contact) {
if (!confirm(`Move "${contact.name}" from device to cache?`)) return;
try {
const response = await fetch(`/api/contacts/${contact.public_key}/move-to-cache`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (data.success) {
showToast(data.message || `${contact.name} moved to cache`, 'success');
setTimeout(() => loadExistingContacts(), 500);
} else {
showToast(data.error || 'Failed to move contact', 'danger');
}
} catch (error) {
showToast('Network error: ' + error.message, 'danger');
}
}
// =============================================================================
// Add Contact Page
// =============================================================================
const TYPE_LABELS = {1: 'COM', 2: 'REP', 3: 'ROOM', 4: 'SENS'};
let html5QrCode = null;
let qrScannedUri = null;
function initAddPage() {
console.log('Initializing Add Contact page...');
// URI tab listeners
const uriInput = document.getElementById('uriInput');
uriInput.addEventListener('input', handleUriInput);
document.getElementById('addFromUriBtn').addEventListener('click', () => submitContact('uri'));
// QR tab listeners
document.getElementById('startCameraBtn').addEventListener('click', startQrCamera);
document.getElementById('stopCameraBtn').addEventListener('click', stopQrCamera);
document.getElementById('qrFileInput').addEventListener('change', handleQrFile);
document.getElementById('addFromQrBtn').addEventListener('click', () => submitContact('qr'));
// Manual tab listeners
const manualKey = document.getElementById('manualKey');
const manualName = document.getElementById('manualName');
manualKey.addEventListener('input', handleManualKeyInput);
manualName.addEventListener('input', validateManualForm);
document.getElementById('addManualBtn').addEventListener('click', () => submitContact('manual'));
// Stop camera when switching away from QR tab
document.getElementById('tab-qr').addEventListener('hidden.bs.tab', stopQrCamera);
}
/**
* Parse a meshcore:// mobile app URI client-side for preview.
* Returns {name, public_key, type} or null.
*/
function parseMeshcoreUri(uri) {
if (!uri || !uri.startsWith('meshcore://')) return null;
try {
const url = new URL(uri);
if (url.hostname !== 'contact' || url.pathname !== '/add') return null;
const name = url.searchParams.get('name');
const publicKey = url.searchParams.get('public_key');
if (!name || !publicKey) return null;
const key = publicKey.trim().toLowerCase();
if (key.length !== 64 || !/^[0-9a-f]{64}$/.test(key)) return null;
let type = parseInt(url.searchParams.get('type') || '1', 10);
if (![1,2,3,4].includes(type)) type = 1;
return { name: name.trim(), public_key: key, type };
} catch {
return null;
}
}
// --- URI Tab ---
function handleUriInput() {
const uri = document.getElementById('uriInput').value.trim();
const preview = document.getElementById('uriPreview');
const btn = document.getElementById('addFromUriBtn');
// Try mobile app format first
const parsed = parseMeshcoreUri(uri);
if (parsed) {
document.getElementById('uriPreviewName').textContent = parsed.name;
document.getElementById('uriPreviewKey').textContent = parsed.public_key;
document.getElementById('uriPreviewType').textContent = TYPE_LABELS[parsed.type] || 'COM';
preview.classList.remove('d-none');
btn.disabled = false;
return;
}
// Hex blob format — can't preview but still valid
if (uri.startsWith('meshcore://') && uri.length > 20) {
preview.classList.add('d-none');
btn.disabled = false;
return;
}
preview.classList.add('d-none');
btn.disabled = true;
}
// --- QR Tab ---
function startQrCamera() {
const readerEl = document.getElementById('qrReader');
if (!readerEl) return;
html5QrCode = new Html5Qrcode('qrReader');
html5QrCode.start(
{ facingMode: 'environment' },
{ fps: 10, qrbox: { width: 250, height: 250 } },
onQrCodeSuccess,
() => {} // ignore scan failures
).then(() => {
document.getElementById('startCameraBtn').classList.add('d-none');
document.getElementById('stopCameraBtn').classList.remove('d-none');
}).catch(err => {
showQrError('Camera access denied or not available. Try uploading an image instead.');
console.error('QR camera error:', err);
});
}
function stopQrCamera() {
if (html5QrCode && html5QrCode.isScanning) {
html5QrCode.stop().catch(() => {});
}
document.getElementById('startCameraBtn').classList.remove('d-none');
document.getElementById('stopCameraBtn').classList.add('d-none');
}
function handleQrFile(event) {
const file = event.target.files[0];
if (!file) return;
const scanner = new Html5Qrcode('qrReader');
scanner.scanFile(file, true)
.then(decodedText => {
onQrCodeSuccess(decodedText);
scanner.clear();
})
.catch(err => {
showQrError('Could not read QR code from image. Make sure the image contains a valid QR code.');
console.error('QR file scan error:', err);
});
}
function onQrCodeSuccess(decodedText) {
const resultDiv = document.getElementById('qrResult');
const errorDiv = document.getElementById('qrError');
const addBtn = document.getElementById('addFromQrBtn');
errorDiv.classList.add('d-none');
const parsed = parseMeshcoreUri(decodedText);
if (parsed) {
document.getElementById('qrResultName').textContent = parsed.name;
document.getElementById('qrResultKey').textContent = parsed.public_key;
document.getElementById('qrResultType').textContent = TYPE_LABELS[parsed.type] || 'COM';
resultDiv.classList.remove('d-none');
addBtn.classList.remove('d-none');
qrScannedUri = decodedText;
stopQrCamera();
return;
}
// Hex blob format
if (decodedText.startsWith('meshcore://') && decodedText.length > 20) {
resultDiv.innerHTML = '<strong>Scanned:</strong> <span class="font-monospace small" style="word-break: break-all;">' +
decodedText.substring(0, 60) + '...</span>';
resultDiv.classList.remove('d-none');
addBtn.classList.remove('d-none');
qrScannedUri = decodedText;
stopQrCamera();
return;
}
showQrError('QR code does not contain a valid meshcore:// URI.');
}
function showQrError(msg) {
const errorDiv = document.getElementById('qrError');
errorDiv.textContent = msg;
errorDiv.classList.remove('d-none');
document.getElementById('qrResult').classList.add('d-none');
document.getElementById('addFromQrBtn').classList.add('d-none');
}
// --- Manual Tab ---
function handleManualKeyInput() {
const input = document.getElementById('manualKey');
// Allow only hex characters
input.value = input.value.replace(/[^0-9a-fA-F]/g, '').toLowerCase();
document.getElementById('manualKeyCount').textContent = `${input.value.length} / 64 characters`;
validateManualForm();
}
function validateManualForm() {
const name = document.getElementById('manualName').value.trim();
const key = document.getElementById('manualKey').value.trim();
const btn = document.getElementById('addManualBtn');
btn.disabled = !(name.length > 0 && key.length === 64 && /^[0-9a-f]{64}$/.test(key));
}
// --- Submit ---
async function submitContact(mode) {
const statusDiv = document.getElementById('addStatus');
let body = {};
if (mode === 'uri') {
body.uri = document.getElementById('uriInput').value.trim();
} else if (mode === 'qr') {
body.uri = qrScannedUri;
} else if (mode === 'manual') {
body.name = document.getElementById('manualName').value.trim();
body.public_key = document.getElementById('manualKey').value.trim();
body.type = parseInt(document.getElementById('manualType').value, 10);
}
// Show loading
statusDiv.className = 'mt-3 alert alert-info';
statusDiv.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Adding contact...';
statusDiv.classList.remove('d-none');
try {
const response = await fetch('/api/contacts/manual-add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await response.json();
if (data.success) {
statusDiv.className = 'mt-3 alert alert-success';
statusDiv.textContent = data.message || 'Contact added successfully!';
// Reset form
resetAddForm(mode);
} else {
statusDiv.className = 'mt-3 alert alert-danger';
statusDiv.textContent = data.error || 'Failed to add contact.';
}
} catch (error) {
statusDiv.className = 'mt-3 alert alert-danger';
statusDiv.textContent = 'Network error: ' + error.message;
}
}
function resetAddForm(mode) {
if (mode === 'uri') {
document.getElementById('uriInput').value = '';
document.getElementById('uriPreview').classList.add('d-none');
document.getElementById('addFromUriBtn').disabled = true;
} else if (mode === 'qr') {
qrScannedUri = null;
document.getElementById('qrResult').classList.add('d-none');
document.getElementById('addFromQrBtn').classList.add('d-none');
document.getElementById('qrFileInput').value = '';
} else if (mode === 'manual') {
document.getElementById('manualName').value = '';
document.getElementById('manualKey').value = '';
document.getElementById('manualKeyCount').textContent = '0 / 64 characters';
document.getElementById('addManualBtn').disabled = true;
}
}
+541 -63
View File
@@ -3,6 +3,64 @@
* Full-page DM view functionality
*/
// --- Settings caches (standalone page — no app.js on this route) ---
const DM_CHAT_SETTINGS_DEFAULTS = {
path_popup_timeout_sec: 8,
path_popup_no_autoclose: false
};
const DM_UI_SETTINGS_DEFAULTS = {
toast_timeout_sec: 2,
toast_no_autoclose: false,
toast_position: 'top-left'
};
const DM_TOAST_POSITION_CLASSES = {
'top-left': ['top-0', 'start-0'],
'top-right': ['top-0', 'end-0'],
'bottom-left': ['bottom-0', 'start-0'],
'bottom-right': ['bottom-0', 'end-0'],
'center': ['top-50', 'start-50', 'translate-middle']
};
const DM_ALL_POSITION_CLASSES = ['top-0', 'top-50', 'start-0', 'start-50', 'bottom-0', 'end-0', 'translate-middle'];
window.chatSettingsCache = window.chatSettingsCache || { ...DM_CHAT_SETTINGS_DEFAULTS };
window.uiSettingsCache = window.uiSettingsCache || { ...DM_UI_SETTINGS_DEFAULTS };
function dmApplyToastPosition(position) {
const classes = DM_TOAST_POSITION_CLASSES[position] || DM_TOAST_POSITION_CLASSES['top-left'];
document.querySelectorAll('[data-toast-container]').forEach(el => {
DM_ALL_POSITION_CLASSES.forEach(c => el.classList.remove(c));
classes.forEach(c => el.classList.add(c));
});
}
async function loadDmChatSettings() {
try {
const resp = await fetch('/api/chat/settings');
if (resp.ok) {
const data = await resp.json();
window.chatSettingsCache = { ...DM_CHAT_SETTINGS_DEFAULTS, ...data };
}
} catch (e) {
console.error('Failed to load chat settings:', e);
}
}
async function loadDmUiSettings() {
try {
const resp = await fetch('/api/ui/settings');
if (resp.ok) {
const data = await resp.json();
window.uiSettingsCache = { ...DM_UI_SETTINGS_DEFAULTS, ...data };
dmApplyToastPosition(window.uiSettingsCache.toast_position);
}
} catch (e) {
console.error('Failed to load UI settings:', e);
}
}
// State variables
let currentConversationId = null;
let currentRecipient = null;
@@ -103,7 +161,7 @@ function connectChatSocket() {
if (!data.expected_ack) return;
// Find message with matching expected_ack in DOM and update status
const msgElements = document.querySelectorAll('#dmMessagesList .dm-message.own');
const msgElements = document.querySelectorAll('#dmMessagesList .dm-message-wrapper.own');
msgElements.forEach(el => {
const statusEl = el.querySelector(`.dm-status[data-ack="${data.expected_ack}"]`);
if (statusEl) {
@@ -112,14 +170,82 @@ function connectChatSocket() {
if (data.snr != null) tooltip.push(`SNR: ${data.snr}`);
if (data.route_type) tooltip.push(`Route: ${data.route_type}`);
statusEl.title = tooltip.length > 0 ? tooltip.join(', ') : 'Delivered';
// Unwrap status icon from wrapper span
const wrapper = statusEl.closest('[data-dm-id]');
if (wrapper) {
wrapper.replaceWith(statusEl);
}
}
});
});
// Real-time DM retry progress
chatSocket.on('dm_retry_status', (data) => {
if (!data.dm_id) return;
const info = document.querySelector(`.dm-retry-info[data-dm-id="${data.dm_id}"]`);
if (info) info.textContent = `Attempt ${data.attempt}/${data.max_attempts}`;
});
// DM retry exhausted — mark as failed, show final attempt count
chatSocket.on('dm_retry_failed', (data) => {
if (!data.dm_id) return;
// Update status icon
const wrapper = document.querySelector(`.dm-status-unknown[data-dm-id="${data.dm_id}"]`);
if (wrapper) {
const icon = wrapper.querySelector('.dm-status');
if (icon) {
icon.className = 'bi bi-x-circle dm-status timeout';
icon.title = 'Delivery failed — all retries exhausted';
}
wrapper.removeAttribute('onclick');
wrapper.classList.remove('dm-status-unknown');
}
// Show final attempt count
const info = document.querySelector(`.dm-retry-info[data-dm-id="${data.dm_id}"]`);
if (info) {
if (data.attempt && data.max_attempts) {
info.textContent = `Attempt ${data.attempt}/${data.max_attempts}`;
} else {
info.textContent = '';
}
}
});
// Real-time delivery info — show attempt count + route after successful delivery
chatSocket.on('dm_delivered_info', (data) => {
if (!data.dm_id) return;
// Find the message element containing this dm_id
const retryEl = document.querySelector(`.dm-retry-info[data-dm-id="${data.dm_id}"]`);
if (!retryEl) return;
retryEl.textContent = '';
const msgDiv = retryEl.closest('.dm-message');
if (!msgDiv) return;
// Build delivery meta text
const parts = [];
if (data.attempt && data.max_attempts) parts.push(`Attempt ${data.attempt}/${data.max_attempts}`);
const hexRoute = formatDmRoute(data.path, data.hash_size);
if (hexRoute) parts.push(`Route: ${hexRoute}`);
if (parts.length > 0) {
let metaEl = msgDiv.querySelector('.dm-delivery-meta');
if (!metaEl) {
metaEl = document.createElement('div');
metaEl.className = 'dm-delivery-meta';
const contentDiv = msgDiv.querySelector('div:nth-child(2)');
if (contentDiv) contentDiv.after(metaEl);
}
metaEl.textContent = parts.join(', ');
}
});
// Real-time device status
chatSocket.on('device_status', (data) => {
updateStatus(data.connected ? 'connected' : 'disconnected');
});
// Real-time path change — always refresh contactsList, re-render modal if open
chatSocket.on('path_changed', async (data) => {
await refreshContactInfoPath();
});
}
// Initialize on page load
@@ -134,6 +260,10 @@ document.addEventListener('DOMContentLoaded', async function() {
// Force reflow to ensure proper layout calculation
document.body.offsetHeight;
// Load settings caches (path popup timeout + toast behavior/position)
loadDmChatSettings();
loadDmUiSettings();
// Load last seen timestamps from server
await loadDmLastSeenTimestampsFromServer();
@@ -168,6 +298,16 @@ document.addEventListener('DOMContentLoaded', async function() {
// Initialize FAB toggle
initializeDmFabToggle();
// Settings FAB - open parent's settings modal
const dmSettingsFab = document.getElementById('dmSettingsFab');
if (dmSettingsFab) {
dmSettingsFab.addEventListener('click', () => {
if (window.parent && window.parent !== window) {
window.parent.postMessage({type: 'openSettings'}, '*');
}
});
}
// Load auto-retry config
loadAutoRetryConfig();
@@ -324,6 +464,25 @@ function setupEventListeners() {
scrollToBottomBtn.classList.remove('visible');
});
}
// DM Sidebar search input (lg+ screens)
const sidebarSearch = document.getElementById('dmSidebarSearch');
if (sidebarSearch) {
sidebarSearch.addEventListener('input', () => {
populateDmSidebar(sidebarSearch.value);
});
}
// Desktop info button (lg+ screens)
const desktopInfoBtn = document.getElementById('dmDesktopInfoBtn');
if (desktopInfoBtn) {
desktopInfoBtn.addEventListener('click', () => {
const modal = new bootstrap.Modal(document.getElementById('dmContactInfoModal'));
populateContactInfoModal();
loadPathSection();
modal.show();
});
}
}
/**
@@ -423,12 +582,17 @@ function populateConversationSelector() {
window._dmDropdownItems = { conversations, contacts };
renderDropdownItems('');
// Also populate DM sidebar (lg+ screens), preserving current search filter
const sidebarSearch = document.getElementById('dmSidebarSearch');
populateDmSidebar(sidebarSearch ? sidebarSearch.value : '');
// Update search input if conversation is selected — re-resolve name in case contacts loaded
if (currentConversationId) {
const bestName = resolveConversationName(currentConversationId);
if (!isPubkey(bestName)) currentRecipient = bestName;
const input = document.getElementById('dmContactSearchInput');
if (input) input.value = displayName(currentRecipient);
updateDmDesktopHeader();
}
}
@@ -519,6 +683,149 @@ function createDropdownItem(name, conversationId, isUnread, contact) {
return el;
}
/**
* Populate the DM sidebar (visible on lg+ screens).
* Mirrors the dropdown data structure but renders as a persistent list.
*/
function populateDmSidebar(query) {
const list = document.getElementById('dmSidebarList');
if (!list) return;
list.innerHTML = '';
const q = (query || '').toLowerCase().trim();
const { conversations = [], contacts = [] } = window._dmDropdownItems || {};
const filteredConvs = q
? conversations.filter(item => (item.name || '').toLowerCase().includes(q))
: conversations;
const filteredContacts = q
? contacts.filter(c => (c.name || '').toLowerCase().includes(q))
: contacts;
if (filteredConvs.length > 0) {
const sep = document.createElement('div');
sep.className = 'dm-sidebar-separator';
sep.textContent = 'Recent conversations';
list.appendChild(sep);
filteredConvs.forEach(item => {
list.appendChild(createSidebarItem(
item.name, item.conversationId, item.isUnread, item.contact));
});
}
if (filteredContacts.length > 0) {
const sep = document.createElement('div');
sep.className = 'dm-sidebar-separator';
sep.textContent = 'Contacts';
list.appendChild(sep);
filteredContacts.forEach(contact => {
const prefix = contact.public_key_prefix || contact.public_key?.substring(0, 12) || '';
const convId = `pk_${prefix}`;
list.appendChild(createSidebarItem(
contact.name, convId, false, contact));
});
}
if (filteredConvs.length === 0 && filteredContacts.length === 0) {
const empty = document.createElement('div');
empty.className = 'dm-sidebar-separator text-center';
empty.textContent = q ? 'No matches' : 'No contacts available';
list.appendChild(empty);
}
}
/**
* Create a single sidebar item element for the DM sidebar.
*/
function createSidebarItem(name, conversationId, isUnread, contact) {
const el = document.createElement('div');
el.className = 'dm-sidebar-item';
el.dataset.conversationId = conversationId;
if (conversationId === currentConversationId) {
el.classList.add('active');
}
if (isUnread) {
const dot = document.createElement('span');
dot.className = 'sidebar-unread-dot';
el.appendChild(dot);
}
const nameSpan = document.createElement('span');
nameSpan.className = 'contact-name';
nameSpan.textContent = displayName(name);
el.appendChild(nameSpan);
if (contact && contact.type_label) {
const badge = document.createElement('span');
badge.className = 'badge';
const colors = { COM: 'bg-primary', REP: 'bg-success', ROOM: 'bg-info', SENS: 'bg-warning' };
badge.classList.add(colors[contact.type_label] || 'bg-secondary');
badge.textContent = contact.type_label;
el.appendChild(badge);
}
el.addEventListener('click', () => selectConversationFromSidebar(conversationId, name));
return el;
}
/**
* Handle selection from the DM sidebar.
*/
async function selectConversationFromSidebar(conversationId, name) {
await selectConversation(conversationId);
if (name && !isPubkey(name)) currentRecipient = name;
updateDmSidebarActive();
// Move focus to message input
const msgInput = document.getElementById('dmMessageInput');
if (msgInput && !msgInput.disabled) msgInput.focus();
}
/**
* Update active state on DM sidebar items.
*/
function updateDmSidebarActive() {
const list = document.getElementById('dmSidebarList');
if (!list) return;
list.querySelectorAll('.dm-sidebar-item').forEach(item => {
const convId = item.dataset.conversationId;
// Flexible matching: handle prefix upgrades
let isActive = convId === currentConversationId;
if (!isActive && currentConversationId && convId) {
// Match if one is a prefix of the other (pk_ based)
if (convId.startsWith('pk_') && currentConversationId.startsWith('pk_')) {
const a = convId.substring(3);
const b = currentConversationId.substring(3);
isActive = a.startsWith(b) || b.startsWith(a);
}
}
item.classList.toggle('active', isActive);
});
}
/**
* Update the desktop contact header (visible on lg+ screens).
*/
function updateDmDesktopHeader() {
const nameEl = document.getElementById('dmDesktopContactName');
const infoBtn = document.getElementById('dmDesktopInfoBtn');
if (!nameEl) return;
if (currentRecipient) {
nameEl.textContent = displayName(currentRecipient);
if (infoBtn) infoBtn.disabled = false;
} else {
nameEl.textContent = '';
if (infoBtn) infoBtn.disabled = true;
}
}
/**
* Handle selection from the searchable dropdown.
*/
@@ -582,6 +889,10 @@ async function selectConversation(conversationId) {
sendBtn.disabled = false;
}
// Update desktop header and sidebar (lg+ screens)
updateDmDesktopHeader();
updateDmSidebarActive();
// Load messages
await loadMessages();
}
@@ -623,11 +934,15 @@ function clearConversation() {
<div class="dm-empty-state">
<i class="bi bi-envelope"></i>
<p class="mb-1">Select a conversation</p>
<small class="text-muted">Choose from the dropdown above or start a new chat from channel messages</small>
<small class="text-muted">Choose from the list or start a new chat from channel messages</small>
</div>
`;
}
// Update desktop header and sidebar
updateDmDesktopHeader();
updateDmSidebarActive();
updateCharCounter();
}
@@ -800,6 +1115,34 @@ function populateContactInfoModal() {
}
}
/**
* Refresh contact data from device and re-render Contact Info modal if open.
* Uses ?refresh=true to bypass server-side cache.
*/
async function refreshContactInfoPath() {
try {
const response = await fetch('/api/contacts/detailed?refresh=true');
const data = await response.json();
if (data.success) {
contactsList = (data.contacts || []).sort((a, b) =>
(a.name || '').localeCompare(b.name || ''));
contactsMap = {};
contactsList.forEach(c => {
if (c.public_key) contactsMap[c.public_key] = c;
});
}
} catch (e) {
console.error('[DM] refreshContactInfoPath fetch error:', e);
return;
}
// Re-populate modal if still open
const modalEl = document.getElementById('dmContactInfoModal');
if (modalEl && modalEl.classList.contains('show')) {
populateContactInfoModal();
loadPathSection();
}
}
/**
* Load messages for current conversation
*/
@@ -874,25 +1217,37 @@ function displayMessages(messages) {
container.innerHTML = '';
messages.forEach(msg => {
const div = document.createElement('div');
div.className = `dm-message ${msg.is_own ? 'own' : 'other'}`;
const side = msg.is_own ? 'own' : 'other';
// Wrapper: time row + bubble
const wrapper = document.createElement('div');
wrapper.className = `dm-message-wrapper ${side}`;
// Status icon for own messages
let statusIcon = '';
if (msg.is_own) {
const ackAttr = msg.expected_ack ? ` data-ack="${msg.expected_ack}"` : '';
const dmIdAttr = msg.id ? ` data-dm-id="${msg.id}"` : '';
if (msg.status === 'delivered') {
let title = 'Delivered';
if (msg.delivery_attempt && msg.delivery_max_attempts) {
title += ` (${msg.delivery_attempt}/${msg.delivery_max_attempts})`;
}
const route = formatDmRoute(msg.delivery_path, msg.path_hash_size);
if (route) title += `, Route: ${route}`;
else if (msg.delivery_route) title += `, ${msg.delivery_route.replace('PATH_', '')}`;
if (msg.delivery_snr !== null && msg.delivery_snr !== undefined) {
title += `, SNR: ${msg.delivery_snr.toFixed(1)} dB`;
}
if (msg.delivery_route) title += ` (${msg.delivery_route})`;
statusIcon = `<i class="bi bi-check2 dm-status delivered"${ackAttr} title="${title}"></i>`;
} else if (msg.status === 'failed') {
statusIcon = `<span${dmIdAttr}><i class="bi bi-x-circle dm-status timeout"${ackAttr} title="Delivery failed — all retries exhausted"></i></span>`;
} else if (msg.status === 'pending') {
statusIcon = `<i class="bi bi-clock dm-status pending"${ackAttr} title="Sending..."></i>`;
} else {
// No ACK received — show clickable "?" with explanation
statusIcon = `<span class="dm-status-unknown" onclick="showDeliveryInfo(this)"><i class="bi bi-question-circle dm-status unknown"${ackAttr}></i></span>`;
// No ACK received — show clickable "?" with retry counter
statusIcon = `<span class="dm-status-unknown"${dmIdAttr} onclick="showDeliveryInfo(this)"><i class="bi bi-question-circle dm-status unknown"${ackAttr}></i></span>`;
}
}
@@ -908,6 +1263,34 @@ function displayMessages(messages) {
}
}
// Delivery info for delivered/failed messages (attempt count + route)
let deliveryMeta = '';
if (msg.is_own && (msg.status === 'delivered' || msg.status === 'failed')
&& msg.delivery_attempt) {
const parts = [];
if (msg.delivery_attempt && msg.delivery_max_attempts) {
parts.push(`Attempt ${msg.delivery_attempt}/${msg.delivery_max_attempts}`);
}
// Show route only for delivered messages (not failed)
if (msg.status === 'delivered') {
const routeHtml = buildDmRouteHtml(msg.delivery_path, msg.path_hash_size);
if (routeHtml) {
parts.push(routeHtml);
} else if (msg.delivery_route) {
parts.push(msg.delivery_route.replace('PATH_', ''));
}
}
deliveryMeta = `<div class="dm-delivery-meta">${parts.join(', ')}</div>`;
}
// Retry counter placeholder — pre-populate for in-progress sends
let retryInfo = '';
if (msg.is_own) {
const isPending = !msg.status || (msg.status !== 'delivered' && msg.status !== 'failed');
const initialText = isPending && msg.expected_ack ? 'Sending...' : '';
retryInfo = `<div class="dm-delivery-meta dm-retry-info" data-dm-id="${msg.id || ''}">${initialText}</div>`;
}
// Resend button for own messages
const resendBtn = msg.is_own ? `
<div class="dm-actions">
@@ -917,17 +1300,25 @@ function displayMessages(messages) {
</div>
` : '';
div.innerHTML = `
<div class="d-flex justify-content-between align-items-center" style="font-size: 0.7rem;">
<span class="text-muted">${formatTime(msg.timestamp)}</span>
${statusIcon}
</div>
<div>${processMessageContent(msg.content)}</div>
// Time row above bubble
const timeRow = document.createElement('div');
timeRow.className = 'dm-time-row';
timeRow.innerHTML = `<span>${formatTime(msg.timestamp)}</span>${statusIcon}`;
wrapper.appendChild(timeRow);
// Message bubble
const bubble = document.createElement('div');
bubble.className = `dm-message ${side}`;
bubble.innerHTML = `
<div class="dm-content">${processMessageContent(msg.content)}</div>
${deliveryMeta}
${retryInfo}
${meta}
${resendBtn}
`;
wrapper.appendChild(bubble);
container.appendChild(div);
container.appendChild(wrapper);
});
// Scroll to bottom
@@ -1072,6 +1463,98 @@ function resendMessage(content) {
input.focus();
}
/**
* Segment a hex path into hop-sized chunks based on hash_size.
* @param {string} hexPath - raw hex string (e.g. "5e0558d1")
* @param {number} hashSize - bytes per hop hash (1, 2, or 3)
* @returns {string[]} array of uppercase hex segments
*/
function segmentHexPath(hexPath, hashSize) {
if (!hexPath || !/^[0-9a-f]+$/i.test(hexPath)) return [];
const chunkLen = (hashSize || 1) * 2;
const segments = [];
for (let i = 0; i < hexPath.length; i += chunkLen) {
segments.push(hexPath.substring(i, i + chunkLen).toUpperCase());
}
return segments;
}
/**
* Format a hex path as route string (e.g. "5e34e761" "5E→34→E7→61")
* Truncates if more than 4 segments. Returns '' for non-hex strings.
*/
function formatDmRoute(hexPath, hashSize) {
const segments = segmentHexPath(hexPath, hashSize || 1);
if (segments.length === 0) return '';
if (segments.length > 4) {
return `${segments[0]}\u2192...\u2192${segments[segments.length - 1]}`;
}
return segments.join('\u2192');
}
/**
* Build a clickable route span for DM delivery meta.
* Short routes are plain text; long routes (>4 hops) are clickable to show full path.
*/
function buildDmRouteHtml(hexPath, hashSize) {
const segments = segmentHexPath(hexPath, hashSize || 1);
if (segments.length === 0) return '';
const short = segments.length > 4
? `${segments[0]}\u2192...\u2192${segments[segments.length - 1]}`
: segments.join('\u2192');
if (segments.length <= 4) return `Route: ${short}`;
const hs = hashSize || 1;
const escaped = hexPath.replace(/'/g, "\\'");
return `<span class="dm-route-link" onclick="showDmRoutePopup(this, '${escaped}', ${hs})">Route: ${short}</span>`;
}
/**
* Show full route popup for DM delivery path (same style as channel path popup)
*/
function showDmRoutePopup(element, hexPath, hashSize) {
const existing = document.querySelector('.path-popup');
if (existing) existing.remove();
const segments = segmentHexPath(hexPath, hashSize || 1);
const fullRoute = segments.join(' \u2192 ');
const commaRoute = segments.join(',');
const popup = document.createElement('div');
popup.className = 'path-popup';
const entry = document.createElement('div');
entry.className = 'path-entry';
entry.innerHTML = `${fullRoute}<span class="path-detail">Hops: ${segments.length}</span>`;
entry.title = 'Tap to copy route';
entry.addEventListener('click', (e) => {
e.stopPropagation();
navigator.clipboard.writeText(commaRoute).then(() => {
const orig = entry.innerHTML;
entry.innerHTML = '<span style="opacity:0.8">Copied!</span>';
setTimeout(() => { entry.innerHTML = orig; }, 1000);
});
});
popup.appendChild(entry);
element.style.position = 'relative';
element.appendChild(popup);
const dismiss = () => popup.remove();
const cfg = window.chatSettingsCache || {};
const noAutoclose = !!cfg.path_popup_no_autoclose;
const timeoutSec = parseInt(cfg.path_popup_timeout_sec, 10);
if (!noAutoclose) {
const ms = (isFinite(timeoutSec) && timeoutSec > 0 ? timeoutSec : 8) * 1000;
setTimeout(dismiss, ms);
}
document.addEventListener('click', function handler(e) {
if (!element.contains(e.target)) {
dismiss();
document.removeEventListener('click', handler);
}
});
}
/**
* Show delivery info popup (mobile-friendly, same pattern as showPathPopup)
*/
@@ -1262,10 +1745,13 @@ function formatTime(timestamp) {
const date = new Date(timestamp * 1000);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (isToday) {
if (date.toDateString() === now.toDateString()) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (date.toDateString() === yesterday.toDateString()) {
return 'Yesterday ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else {
return date.toLocaleDateString([], { month: 'short', day: 'numeric' }) +
' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
@@ -1307,9 +1793,14 @@ function showNotification(message, type = 'info') {
}
}
const cfg = window.uiSettingsCache || {};
const noAutoclose = !!cfg.toast_no_autoclose;
const timeoutSec = parseFloat(cfg.toast_timeout_sec);
const delay = isFinite(timeoutSec) && timeoutSec > 0 ? Math.round(timeoutSec * 1000) : 2000;
const toast = new bootstrap.Toast(toastEl, {
autohide: true,
delay: 1500
autohide: !noAutoclose,
delay: delay
});
toast.show();
}
@@ -1383,11 +1874,21 @@ function initializeDmFabToggle() {
const container = document.getElementById('dmFabContainer');
if (!toggle || !container) return;
// Restore collapsed state (shared with main chat)
if (localStorage.getItem('mc-webui-fab-collapsed') === '1') {
container.classList.add('collapsed');
toggle.title = 'Show buttons';
}
toggle.addEventListener('click', () => {
container.classList.toggle('collapsed');
const isCollapsed = container.classList.contains('collapsed');
toggle.title = isCollapsed ? 'Show buttons' : 'Hide buttons';
localStorage.setItem('mc-webui-fab-collapsed', isCollapsed ? '1' : '0');
});
// Drag-and-drop support
initFabDrag('dmFabContainer', 'dmFabToggle', 'mc-webui-fab-pos-dm');
}
/**
@@ -1480,7 +1981,7 @@ function closeDmFilterBar() {
function applyDmFilter(query) {
currentDmFilterQuery = query.trim();
const container = document.getElementById('dmMessagesList');
const messages = container.querySelectorAll('.dm-message');
const messages = container.querySelectorAll('.dm-message-wrapper');
const matchCountEl = document.getElementById('dmFilterMatchCount');
// Remove any existing no-matches message
@@ -1528,72 +2029,48 @@ function applyDmFilter(query) {
}
/**
* Get text content from a DM message
* DM structure: timestamp div, then content div, then meta/actions
* @param {HTMLElement} msgEl - DM message element
* Get text content from a DM message wrapper
* @param {HTMLElement} wrapperEl - DM message wrapper element
* @returns {string} - Text content
*/
function getDmMessageText(msgEl) {
// The message content is in a div that is not the timestamp row, meta, or actions
const children = msgEl.children;
for (let i = 0; i < children.length; i++) {
const child = children[i];
// Skip timestamp row (has d-flex class), meta, and actions
if (!child.classList.contains('d-flex') &&
!child.classList.contains('dm-meta') &&
!child.classList.contains('dm-actions')) {
return child.textContent || '';
}
}
return '';
function getDmMessageText(wrapperEl) {
const content = wrapperEl.querySelector('.dm-content');
return content ? content.textContent || '' : '';
}
/**
* Highlight matching text in a DM message
* @param {HTMLElement} msgEl - DM message element
* @param {HTMLElement} wrapperEl - DM message wrapper element
* @param {number} index - Message index for tracking
*/
function highlightDmMessageContent(msgEl, index) {
function highlightDmMessageContent(wrapperEl, index) {
const msgId = 'dm_msg_' + index;
const content = wrapperEl.querySelector('.dm-content');
if (!content) return;
// Find content div (not timestamp, not meta, not actions)
const children = Array.from(msgEl.children);
for (const child of children) {
if (!child.classList.contains('d-flex') &&
!child.classList.contains('dm-meta') &&
!child.classList.contains('dm-actions')) {
if (!originalDmMessageContents.has(msgId)) {
originalDmMessageContents.set(msgId, child.innerHTML);
}
const originalHtml = originalDmMessageContents.get(msgId);
child.innerHTML = FilterUtils.highlightMatches(originalHtml, currentDmFilterQuery);
break;
}
if (!originalDmMessageContents.has(msgId)) {
originalDmMessageContents.set(msgId, content.innerHTML);
}
const originalHtml = originalDmMessageContents.get(msgId);
content.innerHTML = FilterUtils.highlightMatches(originalHtml, currentDmFilterQuery);
}
/**
* Restore original DM message content
* @param {HTMLElement} msgEl - DM message element
* @param {HTMLElement} wrapperEl - DM message wrapper element
*/
function restoreDmOriginalContent(msgEl) {
function restoreDmOriginalContent(wrapperEl) {
const container = document.getElementById('dmMessagesList');
const messages = Array.from(container.querySelectorAll('.dm-message'));
const index = messages.indexOf(msgEl);
const wrappers = Array.from(container.querySelectorAll('.dm-message-wrapper'));
const index = wrappers.indexOf(wrapperEl);
const msgId = 'dm_msg_' + index;
if (!originalDmMessageContents.has(msgId)) return;
const children = Array.from(msgEl.children);
for (const child of children) {
if (!child.classList.contains('d-flex') &&
!child.classList.contains('dm-meta') &&
!child.classList.contains('dm-actions')) {
child.innerHTML = originalDmMessageContents.get(msgId);
break;
}
const content = wrapperEl.querySelector('.dm-content');
if (content) {
content.innerHTML = originalDmMessageContents.get(msgId);
}
}
@@ -1939,6 +2416,7 @@ function setupPathFormHandlers(pubkey) {
const data = await response.json();
if (data.success) {
showNotification('Device path reset to FLOOD', 'info');
await refreshContactInfoPath();
} else {
showNotification(data.error || 'Reset failed', 'danger');
}
+169
View File
@@ -0,0 +1,169 @@
// =============================================================================
// FAB Container — Drag-and-Drop & Customization Utilities
// =============================================================================
/**
* Make a FAB container draggable via its toggle button.
* Short click = toggle collapse, drag = reposition.
* Position is persisted to localStorage.
*
* @param {string} containerId - e.g. 'fabContainer' or 'dmFabContainer'
* @param {string} toggleId - e.g. 'fabToggle' or 'dmFabToggle'
* @param {string} storageKey - localStorage key for position
*/
function initFabDrag(containerId, toggleId, storageKey) {
const container = document.getElementById(containerId);
const toggle = document.getElementById(toggleId);
if (!container || !toggle) return;
const DRAG_THRESHOLD = 5; // px movement before drag starts
let dragging = false;
let startX, startY, origLeft, origTop;
let didDrag = false;
// --- Restore saved position ---
restoreFabPosition(container, storageKey);
// --- Pointer events on toggle ---
toggle.addEventListener('pointerdown', onPointerDown);
function onPointerDown(e) {
// Only primary button
if (e.button !== 0) return;
e.preventDefault();
toggle.setPointerCapture(e.pointerId);
const rect = container.getBoundingClientRect();
startX = e.clientX;
startY = e.clientY;
origLeft = rect.left;
origTop = rect.top;
dragging = false;
didDrag = false;
toggle.addEventListener('pointermove', onPointerMove);
toggle.addEventListener('pointerup', onPointerUp);
}
function onPointerMove(e) {
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (!dragging && (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD)) {
dragging = true;
didDrag = true;
// Switch container to left/top positioning for drag
container.style.right = 'auto';
}
if (dragging) {
const newLeft = origLeft + dx;
const newTop = origTop + dy;
container.style.left = newLeft + 'px';
container.style.top = newTop + 'px';
}
}
function onPointerUp(e) {
toggle.removeEventListener('pointermove', onPointerMove);
toggle.removeEventListener('pointerup', onPointerUp);
if (didDrag) {
// Clamp to viewport
clampFabPosition(container);
// Save
saveFabPosition(container, storageKey);
}
// If it was not a drag, let the click event fire naturally (toggle collapse)
// If it was a drag, suppress the click
if (didDrag) {
toggle.addEventListener('click', suppressClick, {once: true, capture: true});
}
}
function suppressClick(e) {
e.stopImmediatePropagation();
e.preventDefault();
}
}
function clampFabPosition(container) {
const rect = container.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let left = rect.left;
let top = rect.top;
// Keep at least 20px of the container visible on each edge
if (left + rect.width < 20) left = 20 - rect.width;
if (left > vw - 20) left = vw - 20;
if (top < 0) top = 0;
if (top > vh - 20) top = vh - 20;
container.style.left = left + 'px';
container.style.top = top + 'px';
}
function saveFabPosition(container, storageKey) {
const rect = container.getBoundingClientRect();
localStorage.setItem(storageKey, JSON.stringify({
left: rect.left,
top: rect.top
}));
}
function restoreFabPosition(container, storageKey) {
const saved = localStorage.getItem(storageKey);
if (!saved) return;
try {
const pos = JSON.parse(saved);
// If viewport is too small (iframe in hidden modal), defer until visible
if (window.innerWidth < 50 || window.innerHeight < 50) {
const poll = setInterval(() => {
if (window.innerWidth >= 50 && window.innerHeight >= 50) {
clearInterval(poll);
container.style.right = 'auto';
container.style.left = pos.left + 'px';
container.style.top = pos.top + 'px';
clampFabPosition(container);
}
}, 100);
return;
}
container.style.right = 'auto';
container.style.left = pos.left + 'px';
container.style.top = pos.top + 'px';
// Re-clamp in case viewport changed
clampFabPosition(container);
} catch (e) {
localStorage.removeItem(storageKey);
}
}
// =============================================================================
// FAB Size & Spacing — apply from localStorage
// =============================================================================
/**
* Apply saved FAB appearance settings (size, gap).
* Called on page load from both main and DM pages.
*/
function applyFabAppearance() {
const size = localStorage.getItem('mc-webui-fab-size');
const gap = localStorage.getItem('mc-webui-fab-gap');
if (size) {
document.documentElement.style.setProperty('--fab-custom-size', size + 'px');
}
if (gap) {
document.documentElement.style.setProperty('--fab-custom-gap', gap + 'px');
}
}
// Auto-apply on load
document.addEventListener('DOMContentLoaded', applyFabAppearance);
+13 -5
View File
@@ -311,11 +311,11 @@ async function handleChannelLinkClick(channelName) {
* @param {string} channelName - Channel name for notification
*/
function switchToChannel(channelIdx, channelName) {
const selector = document.getElementById('channelSelector');
if (selector) {
selector.value = channelIdx;
// Trigger change event to update state and load messages
selector.dispatchEvent(new Event('change'));
if (typeof selectChannelFromDropdown === 'function') {
const channels = window._channelDropdownItems || [];
const ch = channels.find(c => c && c.index === channelIdx);
const name = (ch && ch.name) || channelName || '';
selectChannelFromDropdown(channelIdx, name);
}
}
@@ -360,10 +360,18 @@ async function joinAndSwitchToChannel(channelName) {
/**
* Initialize channel link click handlers using event delegation
*/
let _channelLinkHandlersInitialized = false;
function initializeChannelLinkHandlers() {
// Guard against double registration - otherwise one click fires N handlers
// and sends N duplicate POSTs to /api/channels/join.
if (_channelLinkHandlersInitialized) return;
_channelLinkHandlersInitialized = true;
document.addEventListener('click', function(e) {
if (e.target.classList.contains('channel-link')) {
e.preventDefault();
// Swallow clicks while this link is already handling a request.
if (e.target.classList.contains('loading')) return;
const channelName = e.target.getAttribute('data-channel-name');
if (channelName) {
+479 -11
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="light" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
@@ -12,6 +12,15 @@
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
<script>
(function() {
var t = localStorage.getItem('mc-webui-theme') || 'light';
document.documentElement.setAttribute('data-theme', t);
document.documentElement.setAttribute('data-bs-theme', t);
})();
</script>
<!-- Bootstrap 5 CSS (local) -->
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet">
<!-- Bootstrap Icons (local) -->
@@ -24,6 +33,8 @@
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<!-- Theme CSS (light/dark mode) -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
{% block extra_head %}{% endblock %}
</head>
@@ -36,15 +47,26 @@
{% if device_name %}
<small class="text-white-50 d-none d-sm-inline">- {{ device_name }}</small>
{% endif %}
{% if transport_type == 'ble' %}
<span class="badge bg-info ms-1 d-none d-sm-inline" title="Bluetooth Low Energy">BLE</span>
{% elif transport_type == 'tcp' %}
<span class="badge bg-warning text-dark ms-1 d-none d-sm-inline" title="TCP connection">TCP</span>
{% endif %}
</span>
<div class="d-flex align-items-center gap-2">
<div id="notificationBell" class="btn btn-outline-light position-relative navbar-touch-btn" style="cursor: pointer;" onclick="markAllChannelsRead()" title="Mark all as read">
<i class="bi bi-bell"></i>
</div>
<select id="channelSelector" class="form-select navbar-touch-select" style="width: auto; min-width: 100px;" title="Select channel">
<option value="0">Public</option>
<!-- Channels loaded dynamically via JavaScript -->
</select>
<div class="position-relative channel-mobile-selector" id="channelSelectorWrapper" style="min-width: 80px; max-width: 140px;">
<input type="text"
id="channelSelectorInput"
class="form-control form-select navbar-touch-select"
placeholder="Public"
autocomplete="off"
title="Select channel"
style="cursor: pointer;">
<div id="channelSelectorDropdown" class="channel-selector-dropdown" style="display: none;"></div>
</div>
<button class="btn btn-outline-light navbar-touch-btn" data-bs-toggle="offcanvas" data-bs-target="#mainMenu" title="Menu">
<i class="bi bi-list"></i>
</button>
@@ -260,6 +282,31 @@
</div>
</div>
<!-- Set Channel Region Scope Modal -->
<div class="modal fade" id="regionPickerModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-pin-map"></i>
Set Region Scope for <span id="regionPickerChannelName" class="fw-bold"></span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-info py-2 small mb-3">
<i class="bi bi-info-circle"></i> Only repeaters allowing the selected region will forward messages from this channel.
</div>
<div id="regionPickerList" class="list-group mb-0"></div>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary btn-sm" id="regionPickerSaveBtn">Save</button>
</div>
</div>
</div>
</div>
<!-- Share Channel Modal -->
<div class="modal fade" id="shareChannelModal" tabindex="-1">
<div class="modal-dialog">
@@ -312,6 +359,9 @@
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabDeviceStats" type="button" id="statsTabBtn">Stats</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabDeviceShare" type="button" id="shareTabBtn">Share</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="tabDeviceInfo">
@@ -328,6 +378,13 @@
</div>
</div>
</div>
<div class="tab-pane fade" id="tabDeviceShare">
<div id="deviceShareContent">
<div class="text-center py-3 text-muted">
Click to generate share code
</div>
</div>
</div>
</div>
</div>
</div>
@@ -345,14 +402,157 @@
<div class="modal-body">
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tabSettingsMessages" type="button">Messages</button>
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tabSettingsDevice" type="button">Device</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsMessages" type="button">Messages</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsChat" type="button">Group Chat</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsInterface" type="button">Interface</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsAppearance" type="button">Appearance</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsContacts" type="button">Contacts</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsRegions" type="button">Regions</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="tabSettingsMessages">
<!-- Device Settings Tab -->
<div class="tab-pane fade show active" id="tabSettingsDevice">
<ul class="nav nav-pills nav-fill mb-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active py-1 px-2" data-bs-toggle="pill"
data-bs-target="#tabDevicePublicInfo" type="button">Public Info</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link py-1 px-2" data-bs-toggle="pill"
data-bs-target="#tabDeviceRadio" type="button">Radio Settings</button>
</li>
</ul>
<div class="tab-content">
<!-- Public Info sub-tab -->
<div class="tab-pane fade show active" id="tabDevicePublicInfo">
<form id="devicePublicInfoForm">
<table class="table table-sm table-borderless mb-3 align-middle">
<tbody>
<tr>
<td class="ps-0">Name</td>
<td class="pe-0"><input type="text" class="form-control form-control-sm"
id="settDeviceName" maxlength="32" placeholder="Device name"></td>
</tr>
<tr>
<td class="ps-0">Latitude</td>
<td class="pe-0">
<div class="input-group input-group-sm">
<input type="number" class="form-control form-control-sm"
id="settDeviceLat" step="0.000001" min="-90" max="90" placeholder="0.000000">
<button type="button" class="btn btn-outline-secondary" id="settDevicePickMapBtn"
title="Pick from map"><i class="bi bi-geo-alt"></i></button>
</div>
</td>
</tr>
<tr>
<td class="ps-0">Longitude</td>
<td class="pe-0"><input type="number" class="form-control form-control-sm"
id="settDeviceLon" step="0.000001" min="-180" max="180" placeholder="0.000000"></td>
</tr>
<tr>
<td class="ps-0">Share position in advert
<span class="badge rounded-pill text-muted" data-bs-toggle="tooltip"
title="Include GPS coordinates in device advertisement"><i class="bi bi-info-circle"></i></span>
</td>
<td class="pe-0">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="settDeviceAdvertLoc">
</div>
</td>
</tr>
<tr>
<td class="ps-0">Path hash mode
<span class="badge rounded-pill text-muted" data-bs-toggle="tooltip"
title="Bytes per hop in routing paths. 1B = shortest path, more collisions; 3B = longest, fewest collisions."><i class="bi bi-info-circle"></i></span>
</td>
<td class="pe-0">
<select class="form-select form-select-sm" id="settDevicePathHashMode">
<option value="0">1 byte (default)</option>
<option value="1">2 bytes</option>
<option value="2">3 bytes</option>
</select>
</td>
</tr>
</tbody>
</table>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm">Save</button>
</div>
</form>
</div>
<!-- Radio Settings sub-tab -->
<div class="tab-pane fade" id="tabDeviceRadio">
<form id="deviceRadioForm">
<table class="table table-sm table-borderless mb-3 align-middle">
<tbody>
<tr>
<td class="ps-0" colspan="2">
<select class="form-select form-select-sm" id="settRadioPreset">
<option value="">Load preset...</option>
</select>
</td>
</tr>
<tr>
<td class="ps-0">Frequency (MHz)</td>
<td class="pe-0" style="width:7rem"><input type="number" class="form-control form-control-sm"
id="settRadioFreq" step="0.001" min="100" max="1000"></td>
</tr>
<tr>
<td class="ps-0">Bandwidth (kHz)</td>
<td class="pe-0">
<select class="form-select form-select-sm" id="settRadioBw" style="width:7rem">
<option value="7.8">7.8</option>
<option value="10.4">10.4</option>
<option value="15.6">15.6</option>
<option value="20.8">20.8</option>
<option value="31.25">31.25</option>
<option value="41.7">41.7</option>
<option value="62.5">62.5</option>
<option value="125">125</option>
<option value="250">250</option>
<option value="500">500</option>
</select>
</td>
</tr>
<tr>
<td class="ps-0">Spreading Factor</td>
<td class="pe-0" style="width:7rem"><input type="number" class="form-control form-control-sm"
id="settRadioSf" min="5" max="12"></td>
</tr>
<tr>
<td class="ps-0">Coding Rate</td>
<td class="pe-0" style="width:7rem"><input type="number" class="form-control form-control-sm"
id="settRadioCr" min="5" max="8"></td>
</tr>
<tr>
<td class="ps-0">TX Power (dBm)</td>
<td class="pe-0" style="width:7rem"><input type="number" class="form-control form-control-sm"
id="settRadioTxPower" min="0" max="30"></td>
</tr>
</tbody>
</table>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm">Save</button>
</div>
</form>
</div>
</div>
</div>
<div class="tab-pane fade" id="tabSettingsMessages">
<div id="settingsMessagesContent">
<form id="dmRetrySettingsForm">
<p class="text-muted small mb-3">Retries are counted after the initial send, e.g. 3 retries = 4 total attempts.</p>
@@ -364,7 +564,7 @@
<td class="pe-0" style="width:5rem"><input type="number" class="form-control form-control-sm" id="settDirectMaxRetries" min="0" max="20" value="3"></td>
</tr>
<tr>
<td class="ps-0">Flood retries <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="Flood attempts after direct retries exhausted"><i class="bi bi-info-circle"></i></span></td>
<td class="ps-0">Flood retries <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="Flood attempts after direct retries exhausted (when no configured paths)"><i class="bi bi-info-circle"></i></span></td>
<td class="pe-0"><input type="number" class="form-control form-control-sm" id="settDirectFloodRetries" min="0" max="5" value="1"></td>
</tr>
<tr>
@@ -378,7 +578,7 @@
<table class="table table-sm table-borderless mb-3 align-middle">
<tbody>
<tr>
<td class="ps-0">Max retries <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="Flood attempts when no path is known"><i class="bi bi-info-circle"></i></span></td>
<td class="ps-0">Max retries <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="Flood retry attempts (also used after path rotation)"><i class="bi bi-info-circle"></i></span></td>
<td class="pe-0" style="width:5rem"><input type="number" class="form-control form-control-sm" id="settFloodMaxRetries" min="0" max="10" value="3"></td>
</tr>
<tr>
@@ -416,14 +616,210 @@
</tr>
</tbody>
</table>
<h6 class="text-muted mb-2">Route popup</h6>
<p class="text-muted small mb-2">The popup shown when tapping "SNR | Hops" under a message. Also applies to DMs.</p>
<table class="table table-sm table-borderless mb-3 align-middle">
<tbody>
<tr>
<td class="ps-0">Auto-close after (s) <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="Seconds before the route popup closes automatically"><i class="bi bi-info-circle"></i></span></td>
<td class="pe-0" style="width:5rem"><input type="number" class="form-control form-control-sm" id="settPathPopupTimeout" min="1" max="60" value="8"></td>
</tr>
<tr>
<td class="ps-0">Don't close automatically <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="Popup stays open until you tap outside it"><i class="bi bi-info-circle"></i></span></td>
<td class="pe-0">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="settPathPopupNoAutoclose">
</div>
</td>
</tr>
</tbody>
</table>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm">Save</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="chatSettingsResetBtn">Reset to defaults</button>
</div>
</form>
</div>
<div class="tab-pane fade" id="tabSettingsInterface">
<form id="uiSettingsForm">
<h6 class="text-muted mb-2">Notifications</h6>
<p class="text-muted small mb-2">Controls the small toasts shown after actions (e.g. "Advert Sent", errors).</p>
<table class="table table-sm table-borderless mb-3 align-middle">
<tbody>
<tr>
<td class="ps-0">Auto-close after (s) <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="Seconds before a notification closes automatically"><i class="bi bi-info-circle"></i></span></td>
<td class="pe-0" style="width:5rem"><input type="number" class="form-control form-control-sm" id="settToastTimeout" min="1" max="60" step="0.5" value="2"></td>
</tr>
<tr>
<td class="ps-0">Don't close automatically <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="Notifications stay until dismissed via their close button"><i class="bi bi-info-circle"></i></span></td>
<td class="pe-0">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="settToastNoAutoclose">
</div>
</td>
</tr>
<tr>
<td class="ps-0">Position on screen</td>
<td class="pe-0">
<select class="form-select form-select-sm" id="settToastPosition">
<option value="top-left">Top left</option>
<option value="top-right">Top right</option>
<option value="bottom-left">Bottom left</option>
<option value="bottom-right">Bottom right</option>
<option value="center">Center</option>
</select>
</td>
</tr>
</tbody>
</table>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm">Save</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="uiSettingsResetBtn">Reset to defaults</button>
</div>
</form>
</div>
<div class="tab-pane fade" id="tabSettingsAppearance">
<h6 class="text-muted mb-3">Theme</h6>
<div class="d-flex flex-column gap-2">
<div class="theme-option active" data-theme-value="light" onclick="setTheme('light')">
<div class="theme-option-preview light">
<i class="bi bi-sun"></i>
</div>
<div>
<div class="theme-option-label">Light</div>
<div class="theme-option-desc">Classic bright interface</div>
</div>
</div>
<div class="theme-option" data-theme-value="dark" onclick="setTheme('dark')">
<div class="theme-option-preview dark">
<i class="bi bi-moon-stars" style="color: #60a5fa;"></i>
</div>
<div>
<div class="theme-option-label">Dark</div>
<div class="theme-option-desc">Easy on the eyes, deep navy palette</div>
</div>
</div>
</div>
<hr>
<h6 class="text-muted mb-3">Quick Access Buttons</h6>
<table class="table table-sm table-borderless mb-3 align-middle">
<tbody>
<tr>
<td class="ps-0">Button size (px)</td>
<td class="pe-0 d-flex align-items-center gap-2" style="width:12rem">
<input type="range" class="form-range flex-grow-1" id="settFabSize" min="28" max="72" step="2" value="56">
<span class="text-muted small" id="settFabSizeVal" style="min-width:2.5rem;text-align:right">56</span>
</td>
</tr>
<tr>
<td class="ps-0">Spacing (px)</td>
<td class="pe-0 d-flex align-items-center gap-2">
<input type="range" class="form-range flex-grow-1" id="settFabGap" min="2" max="24" step="1" value="12">
<span class="text-muted small" id="settFabGapVal" style="min-width:2.5rem;text-align:right">12</span>
</td>
</tr>
<tr>
<td class="ps-0">Position</td>
<td class="pe-0">
<button type="button" class="btn btn-outline-secondary btn-sm" id="settFabResetPos">Reset to default</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="tab-pane fade" id="tabSettingsContacts">
<table class="table table-sm table-borderless mb-0 align-middle">
<tbody>
<tr>
<td class="ps-0">Manual approval enabled
<span class="badge rounded-pill text-muted" data-bs-toggle="tooltip"
title="When enabled, new contacts must be manually approved before they can communicate with your node"><i class="bi bi-info-circle"></i></span>
</td>
<td class="pe-0">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="settManualApproval">
</div>
</td>
</tr>
<tr>
<td class="ps-0" id="settSuppressAdvertNotifsLabel">Suppress new advert notifications
<span class="badge rounded-pill text-muted" data-bs-toggle="tooltip"
title="Hide the badge over Contact Management and the browser notification when new pending contacts arrive. The Pending Contacts list itself still shows them. Requires Manual approval ON."><i class="bi bi-info-circle"></i></span>
</td>
<td class="pe-0">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="settSuppressAdvertNotifs">
</div>
</td>
</tr>
<tr>
<td class="ps-0" id="settAutoIgnoreAdvertsLabel">Automatically add new contacts to "Ignored"
<span class="badge rounded-pill text-muted" data-bs-toggle="tooltip"
title="Every new advert is automatically marked as Ignored — no notifications, no badge. Contacts still appear under Existing Contacts (Cache) and can be promoted with 'To Device'. Requires Manual approval ON."><i class="bi bi-info-circle"></i></span>
</td>
<td class="pe-0">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="settAutoIgnoreAdverts">
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="tab-pane fade" id="tabSettingsRegions">
<div class="alert alert-info py-2 small mb-3 mt-2">
<i class="bi bi-info-circle"></i> Only repeaters allowing a region will forward messages tagged with it.
Find standardised region names at
<a href="https://regions.meshcore.nz/" target="_blank" rel="noopener">regions.meshcore.nz</a>.
</div>
<h6 class="mb-2">Region Registry</h6>
<div id="regionsList" class="list-group mb-3">
<div class="text-center text-muted py-3 small">
<div class="spinner-border spinner-border-sm"></div> Loading...
</div>
</div>
<form id="addRegionForm" class="row g-2 mb-0">
<div class="col-8">
<input type="text" class="form-control form-control-sm" id="newRegionName"
placeholder="Region name (e.g. pl, pl-ma)" required maxlength="30" autocomplete="off">
</div>
<div class="col-4">
<button type="submit" class="btn btn-primary btn-sm w-100">
<i class="bi bi-plus-circle"></i> Add
</button>
</div>
</form>
<div class="form-text small">
Tip: pick the default region via the radio button, or select <em>None</em> to fall back to the firmware default. The chosen region is also pushed to the firmware so any untagged channel uses it.
</div>
</div>
</div>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Coordinate Picker Map Modal -->
<div class="modal fade" id="coordPickerModal" tabindex="-1" style="z-index: 1080;">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title"><i class="bi bi-geo-alt"></i> Pick Coordinates</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<div id="coordPickerMap" style="height: 400px; width: 100%;"></div>
</div>
<div class="modal-footer py-2">
<span class="me-auto small text-muted" id="coordPickerLabel">Click on the map to select coordinates</span>
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" id="coordPickerConfirmBtn" disabled>Confirm</button>
</div>
</div>
</div>
</div>
@@ -581,8 +977,8 @@
</div>
</div>
<!-- Toast container for notifications -->
<div class="toast-container position-fixed top-0 start-0 p-3">
<!-- Toast container for notifications (position classes applied by JS from ui_settings) -->
<div class="toast-container position-fixed top-0 start-0 p-3" data-toast-container>
<div id="notificationToast" class="toast" role="alert">
<div class="toast-header">
<strong class="me-auto">mc-webui</strong>
@@ -609,7 +1005,13 @@
<!-- SocketIO for real-time updates -->
<script src="{{ url_for('static', filename='vendor/socket.io/socket.io.min.js') }}"></script>
<!-- FAB Utilities (drag, sizing — must load before app.js) -->
<script src="{{ url_for('static', filename='js/fab-utils.js') }}"></script>
<!-- Custom JS -->
<!-- QR Code generator (for Device Share) -->
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
<!-- PWA Viewport Fix for Android -->
@@ -643,6 +1045,72 @@
}
</script>
<!-- Theme Switching -->
<script>
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.setAttribute('data-bs-theme', theme);
localStorage.setItem('mc-webui-theme', theme);
// Update theme selector UI
document.querySelectorAll('.theme-option').forEach(function(el) {
el.classList.toggle('active', el.getAttribute('data-theme-value') === theme);
});
}
// Initialize theme selector UI on settings modal open
document.addEventListener('DOMContentLoaded', function() {
var current = localStorage.getItem('mc-webui-theme') || 'light';
document.querySelectorAll('.theme-option').forEach(function(el) {
el.classList.toggle('active', el.getAttribute('data-theme-value') === current);
});
// --- FAB appearance controls ---
var fabSizeSlider = document.getElementById('settFabSize');
var fabSizeVal = document.getElementById('settFabSizeVal');
var fabGapSlider = document.getElementById('settFabGap');
var fabGapVal = document.getElementById('settFabGapVal');
var fabResetPos = document.getElementById('settFabResetPos');
// Load saved values
var savedSize = localStorage.getItem('mc-webui-fab-size');
var savedGap = localStorage.getItem('mc-webui-fab-gap');
if (savedSize && fabSizeSlider) { fabSizeSlider.value = savedSize; fabSizeVal.textContent = savedSize; }
if (savedGap && fabGapSlider) { fabGapSlider.value = savedGap; fabGapVal.textContent = savedGap; }
// Live preview on slider change
if (fabSizeSlider) {
fabSizeSlider.addEventListener('input', function() {
var v = this.value;
fabSizeVal.textContent = v;
document.documentElement.style.setProperty('--fab-custom-size', v + 'px');
localStorage.setItem('mc-webui-fab-size', v);
});
}
if (fabGapSlider) {
fabGapSlider.addEventListener('input', function() {
var v = this.value;
fabGapVal.textContent = v;
document.documentElement.style.setProperty('--fab-custom-gap', v + 'px');
localStorage.setItem('mc-webui-fab-gap', v);
});
}
// Reset position button
if (fabResetPos) {
fabResetPos.addEventListener('click', function() {
localStorage.removeItem('mc-webui-fab-pos');
localStorage.removeItem('mc-webui-fab-pos-dm');
var container = document.getElementById('fabContainer');
if (container) {
container.style.left = '';
container.style.right = '16px';
container.style.top = '80px';
}
});
}
});
</script>
{% block extra_scripts %}{% endblock %}
</body>
</html>
+128
View File
@@ -0,0 +1,128 @@
{% extends "contacts_base.html" %}
{% block title %}Add Contact - mc-webui{% endblock %}
{% block extra_head %}
<!-- html5-qrcode for QR scanning -->
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
{% endblock %}
{% block page_content %}
<div id="addPageContent" class="p-3">
<!-- Page Header -->
<div class="mb-3">
<h4 class="mb-2">
<i class="bi bi-person-plus"></i> Add Contact
</h4>
</div>
<!-- Action Buttons -->
<div class="d-flex gap-2 mb-3">
<button class="btn btn-outline-secondary btn-sm" onclick="navigateTo('/contacts/manage');">
<i class="bi bi-arrow-left"></i> Back
</button>
</div>
<!-- Input Mode Tabs -->
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-uri" data-bs-toggle="tab" data-bs-target="#pane-uri" type="button" role="tab">
<i class="bi bi-link-45deg"></i> URI
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-qr" data-bs-toggle="tab" data-bs-target="#pane-qr" type="button" role="tab">
<i class="bi bi-qr-code-scan"></i> QR Code
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-manual" data-bs-toggle="tab" data-bs-target="#pane-manual" type="button" role="tab">
<i class="bi bi-pencil"></i> Manual
</button>
</li>
</ul>
<div class="tab-content">
<!-- URI Paste Tab -->
<div class="tab-pane fade show active" id="pane-uri" role="tabpanel">
<div class="mb-3">
<label for="uriInput" class="form-label">MeshCore URI:</label>
<textarea class="form-control font-monospace" id="uriInput" rows="3"
placeholder="meshcore://contact/add?name=...&public_key=...&type=..."></textarea>
<small class="form-text text-muted">Paste a meshcore:// URI from the MeshCore mobile app</small>
</div>
<!-- URI Preview -->
<div id="uriPreview" class="alert alert-info d-none mb-3">
<strong>Preview:</strong>
<div><span class="text-muted">Name:</span> <span id="uriPreviewName"></span></div>
<div><span class="text-muted">Key:</span> <span id="uriPreviewKey" class="font-monospace small" style="word-break: break-all;"></span></div>
<div><span class="text-muted">Type:</span> <span id="uriPreviewType"></span></div>
</div>
<button class="btn btn-success" id="addFromUriBtn" disabled>
<i class="bi bi-plus-circle"></i> Add Contact
</button>
</div>
<!-- QR Code Tab -->
<div class="tab-pane fade" id="pane-qr" role="tabpanel">
<!-- Camera Scanner -->
<div id="qrScannerContainer" class="mb-3">
<div id="qrReader" style="width: 100%; max-width: 500px;"></div>
<div id="qrCameraButtons" class="d-flex gap-2 mt-2">
<button class="btn btn-primary btn-sm" id="startCameraBtn">
<i class="bi bi-camera-video"></i> Start Camera
</button>
<button class="btn btn-outline-secondary btn-sm d-none" id="stopCameraBtn">
<i class="bi bi-stop-circle"></i> Stop Camera
</button>
</div>
</div>
<!-- File Upload Fallback -->
<div class="mb-3">
<label for="qrFileInput" class="form-label">Or upload a QR code image:</label>
<input type="file" class="form-control" id="qrFileInput" accept="image/*">
</div>
<!-- QR Result -->
<div id="qrResult" class="alert alert-success d-none mb-3">
<strong>Scanned:</strong>
<div><span class="text-muted">Name:</span> <span id="qrResultName"></span></div>
<div><span class="text-muted">Key:</span> <span id="qrResultKey" class="font-monospace small" style="word-break: break-all;"></span></div>
<div><span class="text-muted">Type:</span> <span id="qrResultType"></span></div>
</div>
<div id="qrError" class="alert alert-danger d-none mb-3"></div>
<button class="btn btn-success d-none" id="addFromQrBtn">
<i class="bi bi-plus-circle"></i> Add Contact
</button>
</div>
<!-- Manual Entry Tab -->
<div class="tab-pane fade" id="pane-manual" role="tabpanel">
<div class="mb-3">
<label for="manualName" class="form-label">Name:</label>
<input type="text" class="form-control" id="manualName" placeholder="Contact name" maxlength="32">
</div>
<div class="mb-3">
<label for="manualKey" class="form-label">Public Key (64 hex chars):</label>
<input type="text" class="form-control font-monospace" id="manualKey"
placeholder="e.g. a1b2c3d4..." maxlength="64" pattern="[0-9a-fA-F]{64}">
<small class="form-text text-muted" id="manualKeyCount">0 / 64 characters</small>
</div>
<div class="mb-3">
<label for="manualType" class="form-label">Contact Type:</label>
<select class="form-select" id="manualType">
<option value="1" selected>COM (Companion)</option>
<option value="2">REP (Repeater)</option>
<option value="3">ROOM (Room Server)</option>
<option value="4">SENS (Sensor)</option>
</select>
</div>
<button class="btn btn-success" id="addManualBtn" disabled>
<i class="bi bi-plus-circle"></i> Add Contact
</button>
</div>
</div>
<!-- Status Messages -->
<div id="addStatus" class="mt-3 d-none"></div>
</div>
{% endblock %}
+9 -26
View File
@@ -4,33 +4,16 @@
{% block page_content %}
<div id="managePageContent" class="p-3">
<!-- Page Header -->
<div class="mb-4">
<h2 class="mb-1">
<i class="bi bi-gear"></i> Settings
</h2>
<p class="text-muted small mb-0">Configure contact management preferences</p>
</div>
<!-- Manual Approval Settings Section -->
<div class="compact-setting">
<div class="form-check form-switch mb-0 d-flex align-items-center gap-2">
<input class="form-check-input" type="checkbox" role="switch" id="manualApprovalSwitch" style="cursor: pointer; min-width: 3rem; min-height: 1.5rem;">
<label class="form-check-label mb-0" for="manualApprovalSwitch" style="cursor: pointer; font-weight: 500;">
<span id="switchLabel">Loading...</span>
</label>
</div>
<i class="bi bi-info-circle info-icon"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="When enabled, new contacts must be manually approved before they can communicate with your node"></i>
</div>
<!-- Navigation Section -->
<div class="mb-4">
<h5 class="mb-3">
<i class="bi bi-list-ul"></i> Manage Contacts
</h5>
<!-- Add Contact Card -->
<div class="nav-card" onclick="navigateTo('/contacts/add');" style="border-left: 4px solid #198754;">
<div>
<h6><i class="bi bi-person-plus"></i> Add Contact</h6>
<small class="text-muted">Add from URI, QR code, or manual entry</small>
</div>
<i class="bi bi-chevron-right text-muted"></i>
</div>
<!-- Pending Contacts Card -->
<div class="nav-card" onclick="navigateTo('/contacts/pending');">
@@ -47,7 +30,7 @@
<div class="nav-card" onclick="navigateTo('/contacts/existing');">
<div>
<h6><i class="bi bi-person-lines-fill"></i> Existing Contacts</h6>
<small class="text-muted">View and manage your approved contacts</small>
<small class="text-muted">Manage all stored contacts</small>
</div>
<span class="badge counter-badge counter-ok rounded-pill" id="existingBadge" style="font-size: 1.1rem;">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
+2 -204
View File
@@ -3,208 +3,6 @@
{% block title %}Contact Management - mc-webui{% endblock %}
{% block extra_head %}
<style>
/* Mobile-first custom styles for Contact Management */
/* Compact manual approval section */
.compact-setting {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background-color: #f8f9fa;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.info-icon {
color: #6c757d;
cursor: help;
font-size: 1.1rem;
}
.info-icon:hover {
color: #0d6efd;
}
.pending-contact-card {
background-color: white;
border: 1px solid #dee2e6;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.contact-name {
font-size: 1.1rem;
font-weight: 600;
color: #212529;
margin-bottom: 0.5rem;
word-wrap: break-word;
}
.contact-key {
font-family: 'Courier New', monospace;
font-size: 0.85rem;
color: #6c757d;
word-break: break-all;
margin-bottom: 0.75rem;
}
.btn-action {
min-height: 44px; /* Touch-friendly size */
font-size: 1rem;
}
.empty-state {
text-align: center;
padding: 1.5rem 1rem;
color: #6c757d;
}
.empty-state i {
font-size: 2rem;
margin-bottom: 0.5rem;
opacity: 0.5;
}
.empty-state.compact {
padding: 1rem;
}
.empty-state.compact i {
font-size: 1.5rem;
margin-bottom: 0.25rem;
}
.info-badge {
display: inline-block;
background-color: #e7f3ff;
color: #0c5460;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.9rem;
margin-top: 0.5rem;
}
/* Existing Contacts Styles */
.existing-contact-card {
background-color: white;
border: 1px solid #dee2e6;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s;
}
.existing-contact-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.type-badge {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
font-weight: 600;
}
.contact-info-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.counter-badge {
font-size: 1rem;
padding: 0.35rem 0.75rem;
}
.counter-ok {
background-color: #28a745;
}
.counter-warning {
background-color: #ffc107;
color: #212529;
}
.counter-alarm {
background-color: #dc3545;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.search-toolbar {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.search-toolbar input,
.search-toolbar select {
flex: 1;
min-width: 150px;
}
/* Scrollable contacts lists */
#pendingList {
max-height: 200px;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
#existingList {
/* No max-height limit - let it use available space */
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
/* Dynamic height based on viewport */
max-height: calc(100vh - 400px);
min-height: 300px;
}
@media (max-width: 768px) {
#existingList {
max-height: calc(100vh - 450px);
}
}
/* Custom scrollbar styling */
#existingList::-webkit-scrollbar,
#pendingList::-webkit-scrollbar {
width: 8px;
}
#existingList::-webkit-scrollbar-track,
#pendingList::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
#existingList::-webkit-scrollbar-thumb,
#pendingList::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
#existingList::-webkit-scrollbar-thumb:hover,
#pendingList::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Compact section headers */
.section-compact {
margin-bottom: 0.75rem;
}
</style>
{% endblock %}
{% block content %}
@@ -347,8 +145,8 @@
</div>
</div>
<!-- Toast container for notifications -->
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<!-- Toast container for notifications (position classes applied by JS from ui_settings) -->
<div class="toast-container position-fixed bottom-0 end-0 p-3" data-toast-container>
<div id="contactToast" class="toast" role="alert">
<div class="toast-header">
<strong class="me-auto">Contact Management</strong>
+41 -222
View File
@@ -1,10 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="light" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>{% block title %}Contact Management - mc-webui{% endblock %}</title>
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
<script>
(function() {
var t = localStorage.getItem('mc-webui-theme') || 'light';
document.documentElement.setAttribute('data-theme', t);
document.documentElement.setAttribute('data-bs-theme', t);
})();
</script>
<!-- Favicon -->
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
@@ -24,126 +33,11 @@
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<!-- Theme CSS (light/dark mode) -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
<style>
/* Mobile-first custom styles for Contact Management */
/* Compact manual approval section */
.compact-setting {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background-color: #f8f9fa;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.info-icon {
color: #6c757d;
cursor: help;
font-size: 1.1rem;
}
.info-icon:hover {
color: #0d6efd;
}
.pending-contact-card {
background-color: white;
border: 1px solid #dee2e6;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.contact-name {
font-size: 1.1rem;
font-weight: 600;
color: #212529;
margin-bottom: 0.1rem;
word-wrap: break-word;
}
.contact-key {
font-family: 'Courier New', monospace;
font-size: 0.85rem;
color: #6c757d;
word-break: break-all;
margin-bottom: 0.15rem;
}
.contact-key.clickable-key {
cursor: pointer;
transition: color 0.15s, background-color 0.15s;
padding: 0.15rem 0.3rem;
margin-left: -0.3rem;
border-radius: 0.25rem;
}
.contact-key.clickable-key:hover {
color: #0d6efd;
background-color: #e7f1ff;
}
.contact-key.clickable-key.copied {
color: #198754;
background-color: #d1e7dd;
}
.empty-state {
text-align: center;
padding: 1.5rem 1rem;
color: #6c757d;
}
.empty-state i {
font-size: 2rem;
margin-bottom: 0.5rem;
opacity: 0.5;
}
.empty-state.compact {
padding: 1rem;
}
.empty-state.compact i {
font-size: 1.5rem;
margin-bottom: 0.25rem;
}
.info-badge {
display: inline-block;
background-color: #e7f3ff;
color: #0c5460;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.9rem;
margin-top: 0.5rem;
}
/* Existing Contacts Styles */
.existing-contact-card {
background-color: white;
border: 1px solid #dee2e6;
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s;
}
.existing-contact-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.type-badge {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
font-weight: 600;
}
/* Protected contact styling */
/* Contact Management page layout overrides */
.protection-indicator {
font-size: 0.85rem;
}
@@ -167,7 +61,7 @@
.type-filter-badge[data-type="COM"] {
color: #0d6efd;
background-color: white;
background-color: var(--map-badge-inactive-bg);
border: 2px solid #0d6efd;
}
.type-filter-badge[data-type="COM"].active {
@@ -177,7 +71,7 @@
.type-filter-badge[data-type="REP"] {
color: #198754;
background-color: white;
background-color: var(--map-badge-inactive-bg);
border: 2px solid #198754;
}
.type-filter-badge[data-type="REP"].active {
@@ -187,7 +81,7 @@
.type-filter-badge[data-type="ROOM"] {
color: #0dcaf0;
background-color: white;
background-color: var(--map-badge-inactive-bg);
border: 2px solid #0dcaf0;
}
.type-filter-badge[data-type="ROOM"].active {
@@ -197,7 +91,7 @@
.type-filter-badge[data-type="SENS"] {
color: #ffc107;
background-color: white;
background-color: var(--map-badge-inactive-bg);
border: 2px solid #ffc107;
}
.type-filter-badge[data-type="SENS"].active {
@@ -205,97 +99,17 @@
background-color: #ffc107;
}
.contact-info-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.counter-badge {
font-size: 1rem;
padding: 0.35rem 0.75rem;
}
.counter-ok {
background-color: #28a745;
}
.counter-warning {
background-color: #ffc107;
color: #212529;
}
.counter-alarm {
background-color: #dc3545;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.search-toolbar {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.search-toolbar input,
.search-toolbar select {
flex: 1;
min-width: 150px;
}
/* Scrollable contacts lists */
#pendingList {
height: calc(100vh - 280px);
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
min-height: 300px;
}
/* Scrollable contacts lists - use flexbox to fill remaining space */
#pendingList,
#existingList {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
height: calc(100vh - 260px);
min-height: 300px;
}
/* Custom scrollbar styling */
#existingList::-webkit-scrollbar,
#pendingList::-webkit-scrollbar {
width: 8px;
}
#existingList::-webkit-scrollbar-track,
#pendingList::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
#existingList::-webkit-scrollbar-thumb,
#pendingList::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
#existingList::-webkit-scrollbar-thumb:hover,
#pendingList::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Compact section headers */
.section-compact {
margin-bottom: 0.75rem;
}
/* NEW: Full-screen lists for dedicated pages - fill remaining space */
/* Full-screen lists for dedicated pages */
.contacts-list-fullscreen {
flex: 1 1 0;
min-height: 0;
@@ -304,10 +118,10 @@
padding: 0;
}
/* NEW: Navigation cards on manage page */
/* Navigation cards on manage page */
.nav-card {
background: white;
border: 1px solid #dee2e6;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 1.25rem;
margin-bottom: 1rem;
@@ -319,7 +133,7 @@
}
.nav-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
box-shadow: var(--card-shadow-hover);
}
.nav-card h6 {
@@ -341,8 +155,7 @@
font-size: 0.85rem;
}
/* NEW: Back buttons */
/* Back buttons */
.back-buttons {
display: flex;
gap: 0.5rem;
@@ -355,7 +168,7 @@
min-height: 44px;
}
/* NEW: Cleanup section on manage page */
/* Cleanup section on manage page */
.cleanup-section {
background-color: #fff3cd;
border: 1px solid #ffc107;
@@ -364,6 +177,11 @@
margin-bottom: 1.5rem;
}
[data-theme="dark"] .cleanup-section {
background-color: rgba(255, 193, 7, 0.1);
border-color: rgba(255, 193, 7, 0.3);
}
.cleanup-section h6 {
color: #856404;
margin-bottom: 0.75rem;
@@ -372,6 +190,10 @@
gap: 0.5rem;
}
[data-theme="dark"] .cleanup-section h6 {
color: #ffc107;
}
/* Override global overflow: hidden from style.css for Contact Management pages */
html, body {
overflow: auto !important;
@@ -381,6 +203,8 @@
body {
display: flex;
flex-direction: column;
background-color: var(--bg-body);
color: var(--text-primary);
}
main {
@@ -400,7 +224,6 @@
flex-direction: column;
}
/* Page content containers should fill available space */
#pendingPageContent,
#existingPageContent {
flex: 1 1 0;
@@ -408,10 +231,6 @@
display: flex;
flex-direction: column;
}
/* Mobile responsiveness */
@media (max-width: 768px) {
}
</style>
{% block extra_head %}{% endblock %}
@@ -458,8 +277,8 @@
</div>
</div>
<!-- Toast container for notifications (shared across all contact pages) -->
<div class="toast-container position-fixed top-0 start-0 p-3">
<!-- Toast container for notifications (shared across all contact pages; position via JS from ui_settings) -->
<div class="toast-container position-fixed top-0 start-0 p-3" data-toast-container>
<div id="contactToast" class="toast" role="alert">
<div class="toast-header">
<strong class="me-auto">Contact Management</strong>
+139 -254
View File
@@ -1,10 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="light" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Direct Messages - mc-webui</title>
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
<script>
(function() {
var t = localStorage.getItem('mc-webui-theme') || 'light';
document.documentElement.setAttribute('data-theme', t);
document.documentElement.setAttribute('data-bs-theme', t);
})();
</script>
<!-- Favicon -->
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
@@ -24,275 +33,145 @@
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<!-- Theme CSS (light/dark mode) -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
<!-- Emoji Picker (local) -->
<script type="module" src="{{ url_for('static', filename='vendor/emoji-picker-element/index.js') }}"></script>
<style>
emoji-picker {
--emoji-size: 1.5rem;
--num-columns: 8;
}
.emoji-picker-container {
position: relative;
}
.emoji-picker-popup {
position: absolute;
bottom: 100%;
right: 0;
z-index: 1000;
margin-bottom: 0.5rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
border-radius: 0.5rem;
overflow: hidden;
}
.emoji-picker-popup.hidden {
display: none;
}
/* Mobile responsive adjustments */
@media (max-width: 576px) {
emoji-picker {
--emoji-size: 1.25rem;
--num-columns: 6;
}
.emoji-picker-popup {
right: auto;
left: 0;
width: 100%;
max-width: 100%;
}
}
/* Searchable contact dropdown */
.dm-contact-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1050;
max-height: 300px;
overflow-y: auto;
background: #fff;
border: 1px solid #dee2e6;
border-top: none;
border-radius: 0 0 0.375rem 0.375rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
.dm-contact-item {
padding: 0.5rem 0.75rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
border-bottom: 1px solid #f0f0f0;
}
.dm-contact-item:hover,
.dm-contact-item.active {
background-color: #e9ecef;
}
.dm-contact-item .contact-name {
flex-grow: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dm-contact-item .badge {
font-size: 0.7rem;
}
.dm-dropdown-separator {
padding: 0.25rem 0.75rem;
font-size: 0.75rem;
color: #6c757d;
background: #f8f9fa;
font-weight: 600;
}
/* Path management styles */
.path-list-item {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.5rem;
border: 1px solid #dee2e6;
border-radius: 0.25rem;
margin-bottom: 0.25rem;
font-size: 0.8rem;
background: #fff;
}
.path-list-item.primary {
border-color: #0d6efd;
background: #f0f7ff;
}
.path-list-item .path-hex {
font-family: monospace;
font-size: 0.75rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.path-list-item .path-label {
color: #6c757d;
font-size: 0.7rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.path-list-item .path-actions {
margin-left: auto;
display: flex;
gap: 0.15rem;
flex-shrink: 0;
}
.path-list-item .path-actions .btn {
padding: 0 0.25rem;
font-size: 0.7rem;
line-height: 1.2;
}
.path-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.path-section-header h6 {
font-size: 0.85rem;
margin: 0;
}
.repeater-picker-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.5rem;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
font-size: 0.8rem;
}
.repeater-picker-item:hover {
background-color: #e9ecef;
}
.repeater-picker-item .badge {
font-family: monospace;
font-size: 0.7rem;
}
.path-uniqueness-warning {
color: #dc3545;
font-size: 0.75rem;
}
/* Leaflet z-index fix for Bootstrap modal */
#rptLeafletMap { z-index: 1; }
#rptLeafletMap .leaflet-top,
#rptLeafletMap .leaflet-bottom { z-index: 1000; }
/* Map modal backdrop stacks above Contact Info modal */
</style>
<!-- Inline styles removed - now in style.css -->
</head>
<body>
<!-- Main Content -->
<main>
<div class="container-fluid d-flex flex-column" style="height: 100vh;">
<!-- Conversation Selector Bar -->
<div class="row border-bottom bg-light">
<div class="col-12 p-2">
<div class="d-flex align-items-center gap-2">
<!-- Searchable contact selector -->
<div class="position-relative flex-grow-1" id="dmContactSearchWrapper">
<input type="text"
id="dmContactSearchInput"
class="form-control"
placeholder="Select chat..."
autocomplete="off">
<div id="dmContactDropdown" class="dm-contact-dropdown" style="display: none;"></div>
</div>
<!-- Clear search button -->
<button type="button"
class="btn btn-outline-secondary flex-shrink-0"
id="dmClearSearchBtn"
title="Clear selection"
style="display: none;">
<i class="bi bi-x-lg"></i>
</button>
<!-- Contact info button -->
<button type="button"
class="btn btn-outline-secondary flex-shrink-0"
id="dmContactInfoBtn"
title="Contact info"
disabled>
<i class="bi bi-info-circle"></i>
</button>
<!-- Main content: sidebar + chat -->
<div class="d-flex flex-grow-1 overflow-hidden" style="min-height: 0;">
<!-- DM Sidebar (visible on lg+ screens) -->
<div id="dmSidebar" class="dm-sidebar">
<div class="dm-sidebar-header">
<input type="text"
id="dmSidebarSearch"
class="form-control form-control-sm"
placeholder="Search contacts..."
autocomplete="off">
</div>
<div class="dm-sidebar-list" id="dmSidebarList">
<!-- Populated by JavaScript -->
</div>
</div>
</div>
<!-- Messages Container -->
<div class="row flex-grow-1 overflow-hidden" style="min-height: 0;">
<div class="col-12 position-relative" style="height: 100%;">
<!-- Filter bar overlay -->
<div id="dmFilterBar" class="filter-bar">
<div class="filter-bar-inner">
<input type="text" id="dmFilterInput" class="filter-bar-input" placeholder="Filter messages..." autocomplete="off">
<span id="dmFilterMatchCount" class="filter-match-count"></span>
<button type="button" id="dmFilterClearBtn" class="filter-bar-btn filter-bar-btn-clear" title="Clear">
<i class="bi bi-x"></i>
</button>
<button type="button" id="dmFilterCloseBtn" class="filter-bar-btn filter-bar-btn-close" title="Close">
<i class="bi bi-x-lg"></i>
<!-- Chat Area -->
<div class="flex-grow-1 d-flex flex-column" style="min-width: 0;">
<!-- Conversation Selector Bar (mobile only, hidden on lg+) -->
<div class="dm-mobile-selector border-bottom bg-light">
<div class="p-2">
<div class="d-flex align-items-center gap-2">
<!-- Searchable contact selector -->
<div class="position-relative flex-grow-1" id="dmContactSearchWrapper">
<input type="text"
id="dmContactSearchInput"
class="form-control"
placeholder="Select chat..."
autocomplete="off">
<div id="dmContactDropdown" class="dm-contact-dropdown" style="display: none;"></div>
</div>
<!-- Clear search button -->
<button type="button"
class="btn btn-outline-secondary flex-shrink-0"
id="dmClearSearchBtn"
title="Clear selection"
style="display: none;">
<i class="bi bi-x-lg"></i>
</button>
<!-- Contact info button -->
<button type="button"
class="btn btn-outline-secondary flex-shrink-0"
id="dmContactInfoBtn"
title="Contact info"
disabled>
<i class="bi bi-info-circle"></i>
</button>
</div>
</div>
</div>
<!-- Desktop contact header (visible on lg+ when sidebar is shown) -->
<div class="dm-desktop-header border-bottom bg-light">
<div class="p-2 d-flex align-items-center gap-2">
<span id="dmDesktopContactName" class="fw-medium flex-grow-1 text-truncate"></span>
<button type="button"
class="btn btn-outline-secondary btn-sm flex-shrink-0"
id="dmDesktopInfoBtn"
title="Contact info"
disabled>
<i class="bi bi-info-circle"></i>
</button>
</div>
</div>
<div id="dmMessagesContainer" class="messages-container h-100 overflow-auto p-3">
<div id="dmMessagesList">
<!-- Placeholder shown when no conversation selected -->
<div class="dm-empty-state">
<i class="bi bi-envelope"></i>
<p class="mb-1">Select a conversation</p>
<small class="text-muted">Choose from the dropdown above or start a new chat from channel messages</small>
<!-- Messages Container -->
<div class="flex-grow-1 position-relative overflow-hidden" style="min-height: 0;">
<!-- Filter bar overlay -->
<div id="dmFilterBar" class="filter-bar">
<div class="filter-bar-inner">
<input type="text" id="dmFilterInput" class="filter-bar-input" placeholder="Filter messages..." autocomplete="off">
<span id="dmFilterMatchCount" class="filter-match-count"></span>
<button type="button" id="dmFilterClearBtn" class="filter-bar-btn filter-bar-btn-clear" title="Clear">
<i class="bi bi-x"></i>
</button>
<button type="button" id="dmFilterCloseBtn" class="filter-bar-btn filter-bar-btn-close" title="Close">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<div id="dmMessagesContainer" class="messages-container h-100 overflow-auto p-3">
<div id="dmMessagesList">
<!-- Placeholder shown when no conversation selected -->
<div class="dm-empty-state">
<i class="bi bi-envelope"></i>
<p class="mb-1">Select a conversation</p>
<small class="text-muted">Choose from the list or start a new chat from channel messages</small>
</div>
</div>
</div>
<!-- Scroll to bottom button -->
<button id="dmScrollToBottomBtn" class="scroll-to-bottom-btn" title="Scroll to bottom">
<i class="bi bi-chevron-double-down"></i>
</button>
</div>
<!-- Scroll to bottom button -->
<button id="dmScrollToBottomBtn" class="scroll-to-bottom-btn" title="Scroll to bottom">
<i class="bi bi-chevron-double-down"></i>
</button>
</div>
</div>
<!-- Send Message Form -->
<div class="row border-top bg-light">
<div class="col-12">
<form id="dmSendForm" class="p-3">
<div class="emoji-picker-container">
<div class="input-group">
<textarea
id="dmMessageInput"
class="form-control"
placeholder="Type a message..."
rows="2"
maxlength="500"
disabled
></textarea>
<button type="button" class="btn btn-outline-secondary" id="dmEmojiBtn" title="Insert emoji">
<i class="bi bi-emoji-smile"></i>
</button>
<button type="submit" class="btn btn-success px-4" id="dmSendBtn" disabled>
<i class="bi bi-send"></i>
</button>
<!-- Send Message Form -->
<div class="border-top bg-light">
<form id="dmSendForm" class="p-3">
<div class="emoji-picker-container">
<div class="input-group">
<textarea
id="dmMessageInput"
class="form-control"
placeholder="Type a message..."
rows="2"
maxlength="500"
disabled
></textarea>
<button type="button" class="btn btn-outline-secondary" id="dmEmojiBtn" title="Insert emoji">
<i class="bi bi-emoji-smile"></i>
</button>
<button type="submit" class="btn btn-success px-4" id="dmSendBtn" disabled>
<i class="bi bi-send"></i>
</button>
</div>
<!-- Emoji picker popup (hidden by default) -->
<div id="dmEmojiPickerPopup" class="emoji-picker-popup hidden"></div>
</div>
<!-- Emoji picker popup (hidden by default) -->
<div id="dmEmojiPickerPopup" class="emoji-picker-popup hidden"></div>
<div class="d-flex justify-content-end">
<small class="text-muted"><span id="dmCharCounter">0</span> / 150</small>
</div>
</form>
</div>
<!-- Status Bar -->
<div class="border-top">
<div class="p-2 small text-muted d-flex justify-content-between align-items-center">
<span id="dmStatusText">
<i class="bi bi-circle-fill text-secondary"></i> Connecting...
</span>
<span id="dmLastRefresh">Updated: Never</span>
</div>
<div class="d-flex justify-content-end">
<small class="text-muted"><span id="dmCharCounter">0</span> / 150</small>
</div>
</form>
</div>
</div>
<!-- Status Bar -->
<div class="row border-top">
<div class="col-12">
<div class="p-2 small text-muted d-flex justify-content-between align-items-center">
<span id="dmStatusText">
<i class="bi bi-circle-fill text-secondary"></i> Connecting...
</span>
<span id="dmLastRefresh">Updated: Never</span>
</div>
</div>
</div>
@@ -306,6 +185,9 @@
<button class="fab fab-filter" id="dmFilterFab" title="Filter Messages">
<i class="bi bi-funnel-fill"></i>
</button>
<button class="fab fab-settings" id="dmSettingsFab" title="Settings">
<i class="bi bi-gear-fill"></i>
</button>
</div>
</main>
@@ -449,8 +331,8 @@
</div>
</div>
<!-- Toast container for notifications -->
<div class="toast-container position-fixed top-0 start-0 p-3">
<!-- Toast container for notifications (position classes applied by JS from ui_settings) -->
<div class="toast-container position-fixed top-0 start-0 p-3" data-toast-container>
<div id="notificationToast" class="toast" role="alert">
<div class="toast-header">
<strong class="me-auto">mc-webui</strong>
@@ -477,6 +359,9 @@
<!-- SocketIO for real-time updates -->
<script src="{{ url_for('static', filename='vendor/socket.io/socket.io.min.js') }}"></script>
<!-- FAB Utilities (drag, sizing) -->
<script src="{{ url_for('static', filename='js/fab-utils.js') }}"></script>
<!-- Custom JS -->
<script src="{{ url_for('static', filename='js/dm.js') }}"></script>
+93 -141
View File
@@ -5,160 +5,109 @@
{% block extra_head %}
<!-- Emoji Picker (local) -->
<script type="module" src="{{ url_for('static', filename='vendor/emoji-picker-element/index.js') }}"></script>
<style>
emoji-picker {
--emoji-size: 1.5rem;
--num-columns: 8;
}
.emoji-picker-container {
position: relative;
}
.emoji-picker-popup {
position: absolute;
bottom: 100%;
right: 0;
z-index: 1000;
margin-bottom: 0.5rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
border-radius: 0.5rem;
overflow: hidden;
}
.emoji-picker-popup.hidden {
display: none;
}
/* Mobile responsive adjustments */
@media (max-width: 576px) {
emoji-picker {
--emoji-size: 1.25rem;
--num-columns: 6;
}
.emoji-picker-popup {
right: auto;
left: 0;
width: 100%;
max-width: 100%;
}
}
/* Modal fullscreen - remove all margins and padding */
#dmModal .modal-dialog.modal-fullscreen,
#contactsModal .modal-dialog.modal-fullscreen,
#logsModal .modal-dialog.modal-fullscreen,
#consoleModal .modal-dialog.modal-fullscreen {
margin: 0 !important;
width: 100vw !important;
max-width: 100vw !important;
height: 100vh !important;
max-height: 100vh !important;
}
#dmModal .modal-content,
#contactsModal .modal-content,
#logsModal .modal-content,
#consoleModal .modal-content {
border: none !important;
border-radius: 0 !important;
height: 100vh !important;
}
#dmModal .modal-body,
#contactsModal .modal-body,
#logsModal .modal-body,
#consoleModal .modal-body {
overflow: hidden !important;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid d-flex flex-column" style="height: 100%;">
<!-- Messages Container -->
<div class="row flex-grow-1 overflow-hidden" style="min-height: 0;">
<div class="col-12 position-relative" style="height: 100%;">
<!-- Filter bar overlay -->
<div id="filterBar" class="filter-bar">
<div class="filter-bar-inner">
<div class="filter-input-wrapper">
<input type="text" id="filterInput" class="filter-bar-input" placeholder="Filter messages..." autocomplete="off">
<!-- Filter mentions autocomplete popup -->
<div id="filterMentionsPopup" class="mentions-popup filter-mentions-popup hidden">
<div class="mentions-list" id="filterMentionsList"></div>
</div>
</div>
<button type="button" id="filterMeBtn" class="filter-bar-btn filter-bar-btn-me" title="Filter my messages">
<i class="bi bi-person-fill"></i>
</button>
<span id="filterMatchCount" class="filter-match-count"></span>
<button type="button" id="filterClearBtn" class="filter-bar-btn filter-bar-btn-clear" title="Clear">
<i class="bi bi-x"></i>
</button>
<button type="button" id="filterCloseBtn" class="filter-bar-btn filter-bar-btn-close" title="Close">
<i class="bi bi-x-lg"></i>
</button>
</div>
<!-- Main content: sidebar + chat -->
<div class="d-flex flex-grow-1 overflow-hidden" style="min-height: 0;">
<!-- Channel Sidebar (visible on lg+ screens) -->
<div id="channelSidebar" class="channel-sidebar">
<div class="channel-sidebar-header">
<i class="bi bi-broadcast-pin"></i> Channels
</div>
<div id="messagesContainer" class="messages-container h-100 overflow-auto p-3">
<div id="messagesList">
<!-- Messages will be loaded here via JavaScript -->
<div class="text-center text-muted py-5">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3">Loading messages...</p>
</div>
</div>
<div class="channel-sidebar-list" id="channelSidebarList">
<!-- Populated by JavaScript -->
</div>
<!-- Scroll to bottom button -->
<button id="scrollToBottomBtn" class="scroll-to-bottom-btn" title="Scroll to bottom">
<i class="bi bi-chevron-double-down"></i>
</button>
</div>
</div>
<!-- Send Message Form -->
<div class="row border-top bg-light">
<div class="col-12">
<form id="sendMessageForm" class="p-3">
<div class="emoji-picker-container">
<div class="input-group">
<textarea
id="messageInput"
class="form-control"
placeholder="Type a message..."
rows="2"
maxlength="500"
required
></textarea>
<button type="button" class="btn btn-outline-secondary" id="emojiBtn" title="Insert emoji">
<i class="bi bi-emoji-smile"></i>
<!-- Chat Area -->
<div class="flex-grow-1 d-flex flex-column" style="min-width: 0;">
<!-- Messages Container -->
<div class="flex-grow-1 position-relative overflow-hidden" style="min-height: 0;">
<!-- Filter bar overlay -->
<div id="filterBar" class="filter-bar">
<div class="filter-bar-inner">
<div class="filter-input-wrapper">
<input type="text" id="filterInput" class="filter-bar-input" placeholder="Filter messages..." autocomplete="off">
<!-- Filter mentions autocomplete popup -->
<div id="filterMentionsPopup" class="mentions-popup filter-mentions-popup hidden">
<div class="mentions-list" id="filterMentionsList"></div>
</div>
</div>
<button type="button" id="filterMeBtn" class="filter-bar-btn filter-bar-btn-me" title="Filter my messages">
<i class="bi bi-person-fill"></i>
</button>
<button type="submit" class="btn btn-primary px-4" id="sendBtn">
<i class="bi bi-send"></i>
<span id="filterMatchCount" class="filter-match-count"></span>
<button type="button" id="filterClearBtn" class="filter-bar-btn filter-bar-btn-clear" title="Clear">
<i class="bi bi-x"></i>
</button>
<button type="button" id="filterCloseBtn" class="filter-bar-btn filter-bar-btn-close" title="Close">
<i class="bi bi-x-lg"></i>
</button>
</div>
<!-- Emoji picker popup (hidden by default) -->
<div id="emojiPickerPopup" class="emoji-picker-popup hidden"></div>
<!-- Mentions autocomplete popup (hidden by default) -->
<div id="mentionsPopup" class="mentions-popup hidden">
<div class="mentions-list" id="mentionsList"></div>
</div>
<div id="messagesContainer" class="messages-container h-100 overflow-auto p-3">
<div id="messagesList">
<!-- Messages will be loaded here via JavaScript -->
<div class="text-center text-muted py-5">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3">Loading messages...</p>
</div>
</div>
</div>
<div class="d-flex justify-content-end">
<small id="charCounter" class="text-muted">0 / 135</small>
<!-- Scroll to bottom button -->
<button id="scrollToBottomBtn" class="scroll-to-bottom-btn" title="Scroll to bottom">
<i class="bi bi-chevron-double-down"></i>
</button>
</div>
<!-- Send Message Form -->
<div class="border-top bg-light">
<form id="sendMessageForm" class="p-3">
<div class="emoji-picker-container">
<div class="input-group">
<textarea
id="messageInput"
class="form-control"
placeholder="Type a message..."
rows="2"
maxlength="500"
required
></textarea>
<button type="button" class="btn btn-outline-secondary" id="emojiBtn" title="Insert emoji">
<i class="bi bi-emoji-smile"></i>
</button>
<button type="submit" class="btn btn-primary px-4" id="sendBtn">
<i class="bi bi-send"></i>
</button>
</div>
<!-- Emoji picker popup (hidden by default) -->
<div id="emojiPickerPopup" class="emoji-picker-popup hidden"></div>
<!-- Mentions autocomplete popup (hidden by default) -->
<div id="mentionsPopup" class="mentions-popup hidden">
<div class="mentions-list" id="mentionsList"></div>
</div>
</div>
<div class="d-flex justify-content-end">
<small id="charCounter" class="text-muted">0 / 135</small>
</div>
</form>
</div>
<!-- Status Bar -->
<div class="border-top">
<div class="p-2 small text-muted d-flex justify-content-between align-items-center">
<div class="d-flex gap-2 align-items-center">
<span id="statusText">
<i class="bi bi-circle-fill text-secondary"></i> Connecting...
</span>
<span id="regionIndicator" class="badge bg-info text-dark d-none" role="button"
title="Click to change region for this channel">
<i class="bi bi-pin-map"></i> <span id="regionIndicatorName"></span>
</span>
</div>
<span id="lastRefresh">Updated: Never</span>
</div>
</form>
</div>
</div>
<!-- Status Bar -->
<div class="row border-top">
<div class="col-12">
<div class="p-2 small text-muted d-flex justify-content-between align-items-center">
<span id="statusText">
<i class="bi bi-circle-fill text-secondary"></i> Connecting...
</span>
<span id="lastRefresh">Updated: Never</span>
</div>
</div>
</div>
@@ -181,6 +130,9 @@
<button class="fab fab-contacts" data-bs-toggle="modal" data-bs-target="#contactsModal" title="Contact Management">
<i class="bi bi-person-fill"></i>
</button>
<button class="fab fab-settings" data-bs-toggle="modal" data-bs-target="#settingsModal" title="Settings">
<i class="bi bi-gear-fill"></i>
</button>
</div>
<!-- DM Modal (Full Screen) -->
+6
View File
@@ -11,15 +11,21 @@ services:
device_cgroup_rules:
- 'c 188:* rmw'
- 'c 166:* rmw'
# NET_ADMIN + NET_RAW for BLE scanning (no overhead when BLE unused)
cap_add:
- NET_ADMIN
- NET_RAW
volumes:
- "${MC_CONFIG_DIR:-./data}:/data:rw"
- "/dev:/dev"
- "/var/run/dbus:/var/run/dbus" # BlueZ D-Bus (for BLE)
environment:
- MC_SERIAL_PORT=${MC_SERIAL_PORT:-auto}
- MC_DEVICE_NAME=${MC_DEVICE_NAME:-MeshCore}
- MC_CONFIG_DIR=/data
- MC_TCP_HOST=${MC_TCP_HOST:-}
- MC_TCP_PORT=${MC_TCP_PORT:-5555}
- MC_BLE_ADDRESS=${MC_BLE_ADDRESS:-}
- MC_BACKUP_ENABLED=${MC_BACKUP_ENABLED:-true}
- MC_BACKUP_HOUR=${MC_BACKUP_HOUR:-2}
- MC_BACKUP_RETENTION_DAYS=${MC_BACKUP_RETENTION_DAYS:-7}
+69 -15
View File
@@ -21,7 +21,7 @@ Technical documentation for mc-webui, covering system architecture, project stru
- **Frontend:** HTML5, Bootstrap 5, vanilla JavaScript, Socket.IO client
- **Deployment:** Docker / Docker Compose (Single-container architecture)
- **Communication:** Direct hardware access (USB, BLE, or TCP) via `meshcore` library
- **Data source:** SQLite Database (`./data/meshcore/<device_name>.db`)
- **Data source:** SQLite Database (`./data/meshcore/<pubkey_prefix>.db`)
---
@@ -37,7 +37,7 @@ mc-webui uses a **single-container architecture** for simplified deployment and
│ │ mc-webui │ │
│ │ │ │
│ │ - Flask web app (Port 5000) │ │
│ │ - DeviceManager (Direct USB/TCP access) │ │
│ │ - DeviceManager (Direct USB/BLE/TCP access) │ │
│ │ - Database (SQLite) │ │
│ │ │ │
│ └─────────┬─────────────────────────────────────────────┘ │
@@ -46,13 +46,23 @@ mc-webui uses a **single-container architecture** for simplified deployment and
┌──────────────┐
USB/TCP
│ Device
│ USB/BLE/TCP │
Device │
└──────────────┘
```
Three transport options are supported with the following priority: **BLE > TCP > Serial (USB)**. Set the `MC_BLE_ADDRESS` or `MC_TCP_HOST` environment variable to activate BLE or TCP transport respectively; otherwise, USB serial is used by default.
This v2 architecture eliminates the need for a separate bridge container and relies on the native `meshcore` Python library for direct communication, ensuring lower latency and greater stability.
### Docker Entrypoint (BLE cleanup)
`scripts/docker-entrypoint.sh` runs before the Flask app starts. When `MC_BLE_ADDRESS` is set, it uses D-Bus to check if BlueZ has an active session to the device and disconnects it. BlueZ auto-reconnects trusted devices, which leaves stale GATT notification handles that block `bleak` from establishing a new session. A clean disconnect at startup ensures the app starts with a fresh BLE state.
### Multi-architecture Images
Official images are built via GitHub Actions for `linux/amd64`, `linux/arm64`, and `linux/arm/v7` (Raspberry Pi 2/3/4/5 supported). Build dependencies (`gcc`, `python3-dev`, `libjpeg-dev`, `zlib1g-dev`) are installed and then purged to keep the final image size small while still compiling `Pillow` and `pycryptodome` from source when wheels are unavailable (notably on `arm/v7`). GHA layer cache (`cache-from` / `cache-to`) speeds up subsequent rebuilds. Images are pushed to both Docker Hub (`mawoj/mc-webui`) and GitHub Container Registry (`ghcr.io/marekwo/mc-webui`), with `latest` tag on `main` and `dev` tag on the `dev` branch.
---
## DeviceManager Architecture
@@ -65,6 +75,9 @@ The `DeviceManager` handles the connection to the MeshCore device via a direct s
- **Real-time messages** - Instant message processing via callback events without polling
- **Thread-safe queue** - Commands are serialized to prevent device lockups
- **Auto-restart watchdog** - Monitors connection health and restarts the session on crash
- **BLE keepalive & reconnect** - When using Bluetooth transport, a 60s keepalive loop detects "zombie" connections (reads still succeed but writes silently fail). On disconnect or keepalive failure, the manager marks the session as permanently failed and the `/health` endpoint returns 503, letting the Docker healthcheck trigger a fast container restart (~5s) to get a clean BLE state rather than attempting unreliable in-process reconnects
- **Echo correlation** - Sent channel messages pre-compute their expected `pkt_payload` using the channel secret and send timestamp (±3s for clock drift), so incoming echoes are matched exactly instead of only by 1-byte channel hash (prevents misattribution when two messages go out simultaneously on the same channel)
- **Per-channel region scope** - Before each channel send, the channel's mapped region scope key (16 bytes) is pushed to the firmware via `CMD_SET_FLOOD_SCOPE_KEY` (54). The scope-set + send pair is serialised under a `_send_lock` so concurrent sends on different channels can't swap each other's scope. Channels without a mapping get an all-zero key so a previously-set scope doesn't leak across channels
---
@@ -80,8 +93,8 @@ mc-webui/
│ ├── config.py # Configuration from env vars
│ ├── database.py # SQLite database models and CRUD operations
│ ├── device_manager.py # Core logic for meshcore communication
│ ├── contacts_cache.py # Persistent contacts cache
│ ├── read_status.py # Server-side read status manager
│ ├── contacts_cache.py # Persistent contacts cache (DB-backed)
│ ├── read_status.py # Server-side read status manager (DB-backed)
│ ├── version.py # Git-based version management
│ ├── migrate_v1.py # Migration script from v1 flat files to v2 SQLite
│ ├── meshcore/
@@ -95,9 +108,14 @@ mc-webui/
│ │ ├── api.py # REST API endpoints
│ │ └── views.py # HTML views
│ ├── static/ # Frontend assets (CSS, JS, images, vendors)
│ │ └── js/fab-utils.js # Floating-button drag/collapse/sizing helpers
│ └── templates/ # HTML templates
├── docs/ # Documentation
├── scripts/ # Utility scripts (update, watchdog, updater)
├── scripts/
│ ├── update.sh # Automated update script
│ ├── docker-entrypoint.sh # Container startup (BLE cleanup)
│ ├── updater/ # Remote update webhook service
│ └── watchdog/ # Container health monitor
└── README.md
```
@@ -107,15 +125,18 @@ mc-webui/
mc-webui v2 uses a robust **SQLite Database** with WAL (Write-Ahead Logging) enabled.
Location: `./data/meshcore/<device_name>.db`
Location: `./data/meshcore/<pubkey_prefix>.db`
Key tables:
- `messages` - All channel and direct messages (with FTS5 index for full-text search)
- `contacts` - Contact list with sync status, types, block/ignore flags
- `contacts` - Contact list with sync status, types, block/ignore flags, `no_auto_flood` flag
- `channels` - Channel configuration and keys
- `echoes` - Sent message tracking and repeater paths
- `echoes` - Sent message tracking and repeater paths, `hash_size` for path_hash_mode
- `direct_messages` - DM messages with delivery tracking (`delivery_status`, `delivery_attempt`, `delivery_max_attempts`, `delivery_path`)
- `acks` - DM delivery status
- `settings` - Application settings (migrated from .webui_settings.json)
- `regions` - User-curated MeshCore flood scopes (`name`, `key_hex`, `is_default`)
- `channel_scopes` - Per-channel region mapping (`channel_idx``region_id`, CASCADE on region delete; absent row = no override → firmware default applies)
The use of SQLite allows for fast queries, reliable data storage, full-text search, and complex filtering (such as contact ignoring/blocking) without the risk of file corruption inherent to flat JSON files.
@@ -157,18 +178,43 @@ The use of SQLite allows for fast queries, reliable data storage, full-text sear
| POST | `/api/contacts/pending/approve` | Approve pending contact |
| POST | `/api/contacts/pending/reject` | Reject pending contact |
| POST | `/api/contacts/pending/clear` | Clear all pending contacts |
| POST | `/api/contacts/manual-add` | Add contact from URI or params |
| POST | `/api/contacts/<key>/push-to-device` | Push cached contact to device |
| POST | `/api/contacts/<key>/move-to-cache` | Move device contact to cache |
| GET | `/api/contacts/repeaters` | List repeater contacts (for path picker) |
| GET | `/api/contacts/<key>/paths` | Get contact paths |
| POST | `/api/contacts/<key>/paths` | Add path to contact |
| PUT | `/api/contacts/<key>/paths/<id>` | Update path (star, label) |
| DELETE | `/api/contacts/<key>/paths/<id>` | Delete path |
| POST | `/api/contacts/<key>/paths/reorder` | Reorder paths |
| POST | `/api/contacts/<key>/paths/reset_flood` | Reset to FLOOD routing |
| POST | `/api/contacts/<key>/paths/clear` | Clear all paths |
| GET | `/api/contacts/<key>/no_auto_flood` | Get "Keep path" flag |
| PUT | `/api/contacts/<key>/no_auto_flood` | Set "Keep path" flag |
### Channels
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/channels` | List all channels |
| POST | `/api/channels` | Create new channel |
| POST | `/api/channels/join` | Join existing channel |
| POST | `/api/channels` | Create new channel (idempotent — returns existing slot if name already used) |
| POST | `/api/channels/join` | Join existing channel (idempotent unless explicit `index` overrides) |
| DELETE | `/api/channels/<index>` | Remove channel |
| GET | `/api/channels/<index>/qr` | QR code (`?format=json\|png`) |
| GET | `/api/channels/muted` | Get muted channels |
| POST | `/api/channels/<index>/mute` | Toggle channel mute |
| GET | `/api/channels/scopes` | Bulk per-channel region mapping for UI |
| PUT | `/api/channels/<index>/scope` | Assign/clear region scope (`{region_id: int\|null}`) |
### Regions (MeshCore flood scopes)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/regions` | List the device's region registry |
| POST | `/api/regions` | Create region (`{name}`); key derived as `SHA256('#'+name)[:16]` |
| DELETE | `/api/regions/<id>` | Delete region; CASCADE clears channel mappings; if it was the firmware default, clears it on device |
| POST | `/api/regions/<id>/default` | Mark default in DB AND push to firmware (CMD_SET_DEFAULT_FLOOD_SCOPE = 63, requires firmware v1.15+) |
| DELETE | `/api/regions/default` | Clear default region in DB and on firmware |
### Direct Messages
@@ -185,15 +231,19 @@ The use of SQLite allows for fast queries, reliable data storage, full-text sear
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/status` | Connection status (device name, serial port) |
| GET | `/api/status` | Connection status (device name, transport type, serial port / BLE address) |
| GET | `/api/device/info` | Device information |
| GET | `/api/device/stats` | Device statistics |
| GET | `/api/device/settings` | Get device settings |
| POST | `/api/device/settings` | Update device settings |
| GET | `/api/device/config` | Get device configuration (name, coords, advert_loc_policy, path_hash_mode, radio params, tx_power) |
| POST | `/api/device/config` | Update device configuration from Settings > Device tab. Subset of fields incl. `path_hash_mode` (0=1B, 1=2B, 2=3B) |
| POST | `/api/device/command` | Execute command (advert, floodadv) |
| GET | `/api/device/commands` | List available special commands |
| GET | `/api/chat/settings` | Get chat settings (quote length) |
| GET | `/api/chat/settings` | Get chat settings (quote length, route popup timeout/no-autoclose) |
| POST | `/api/chat/settings` | Update chat settings |
| GET | `/api/ui/settings` | Get UI settings (toast timeout, no-autoclose, position) |
| POST | `/api/ui/settings` | Update UI settings |
| GET | `/api/retention-settings` | Get message retention settings |
| POST | `/api/retention-settings` | Update retention settings |
@@ -246,8 +296,12 @@ Real-time message delivery via Socket.IO.
**Server → Client:**
- `new_channel_message` - New channel message received
- `new_dm_message` - New DM received
- `message_echo` - Echo/ACK update for sent message
- `message_echo` - Echo/ACK update for sent message (includes `hash_size`)
- `dm_ack` - DM delivery confirmation
- `dm_retry_status` - Real-time retry progress (`dm_id`, `attempt`, `max_attempts`)
- `dm_retry_failed` - All retry attempts exhausted (`dm_id`)
- `dm_delivered_info` - Delivery details after ACK (`dm_id`, `attempt`, `max_attempts`, `path`, `hash_size`)
- `path_changed` - Contact path discovered/updated (`public_key`)
### Logs Namespace (`/logs`)
+771
View File
@@ -0,0 +1,771 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contact Management in mc-webui</title>
<meta name="description" content="A deep dive into contact management in mc-webui: device contacts, the cache layer, ignored and blocked flags, recommended settings, and migration from the official MeshCore apps.">
<style>
:root {
--bg: #ffffff;
--fg: #1f2328;
--muted: #656d76;
--border: #d0d7de;
--border-soft: #eaeef2;
--surface: #f6f8fa;
--surface-hover: #eef1f4;
--accent: #0969da;
--accent-soft: #ddf4ff;
--code-bg: #f6f8fa;
--table-row-alt: #f6f8fa;
--note-bg: #ddf4ff;
--note-border: #54aeff;
--warn-bg: #fff8c5;
--warn-border: #d4a72c;
--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
--max-width: 820px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d1117;
--fg: #e6edf3;
--muted: #9198a1;
--border: #30363d;
--border-soft: #21262d;
--surface: #161b22;
--surface-hover: #1f252d;
--accent: #4493f8;
--accent-soft: #1f2d3d;
--code-bg: #161b22;
--table-row-alt: #161b22;
--note-bg: #1f2d3d;
--note-border: #4493f8;
--warn-bg: #3b2e05;
--warn-border: #bb8009;
}
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
font-family: var(--font-body);
font-size: 16px;
line-height: 1.6;
color: var(--fg);
background: var(--bg);
margin: 0;
padding: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.container {
max-width: var(--max-width);
margin: 0 auto;
padding: 2.5rem 1.25rem 4rem;
}
header.page-header {
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border-soft);
}
header.page-header .eyebrow {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.75rem;
font-weight: 600;
color: var(--muted);
margin-bottom: 0.75rem;
}
h1 {
font-size: 2.1rem;
line-height: 1.25;
margin: 0 0 0.5rem;
font-weight: 700;
letter-spacing: -0.015em;
}
.lede {
font-size: 1.05rem;
color: var(--muted);
margin: 0;
}
h2 {
font-size: 1.5rem;
margin-top: 2.75rem;
margin-bottom: 0.75rem;
padding-top: 0.5rem;
font-weight: 650;
letter-spacing: -0.01em;
border-top: 1px solid var(--border-soft);
padding-top: 1.75rem;
}
h2:first-of-type {
border-top: none;
padding-top: 0;
}
h3 {
font-size: 1.2rem;
margin-top: 1.75rem;
margin-bottom: 0.5rem;
font-weight: 650;
}
h4 {
font-size: 1.05rem;
margin-top: 1.25rem;
margin-bottom: 0.4rem;
font-weight: 600;
}
p {
margin: 0 0 1rem;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
ul, ol {
margin: 0 0 1rem;
padding-left: 1.5rem;
}
li {
margin-bottom: 0.35rem;
}
li > p {
margin-bottom: 0.35rem;
}
strong {
font-weight: 650;
color: var(--fg);
}
code {
font-family: var(--font-mono);
font-size: 0.88em;
background: var(--code-bg);
padding: 0.15em 0.4em;
border-radius: 4px;
border: 1px solid var(--border-soft);
}
pre {
background: var(--code-bg);
border: 1px solid var(--border-soft);
border-radius: 6px;
padding: 1rem;
overflow-x: auto;
margin: 1rem 0;
}
pre code {
background: transparent;
border: none;
padding: 0;
font-size: 0.9em;
}
blockquote {
margin: 1.25rem 0;
padding: 0.75rem 1rem;
border-left: 4px solid var(--note-border);
background: var(--note-bg);
border-radius: 0 6px 6px 0;
color: var(--fg);
}
blockquote p:last-child {
margin-bottom: 0;
}
table {
border-collapse: collapse;
width: 100%;
margin: 1.25rem 0;
font-size: 0.95rem;
}
th, td {
text-align: left;
padding: 0.55rem 0.75rem;
border: 1px solid var(--border);
vertical-align: top;
}
th {
background: var(--surface);
font-weight: 650;
}
tr:nth-child(even) td {
background: var(--table-row-alt);
}
hr {
border: none;
border-top: 1px solid var(--border-soft);
margin: 2.5rem 0;
}
/* Table of contents */
nav.toc {
background: var(--surface);
border: 1px solid var(--border-soft);
border-radius: 8px;
padding: 1.25rem 1.5rem;
margin: 2rem 0;
}
nav.toc h2 {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 0 0 0.75rem;
border: none;
padding: 0;
color: var(--muted);
font-weight: 600;
}
nav.toc ul {
margin: 0;
padding-left: 1.25rem;
column-count: 1;
}
@media (min-width: 640px) {
nav.toc ul {
column-count: 2;
column-gap: 2rem;
}
}
nav.toc li {
margin-bottom: 0.4rem;
break-inside: avoid;
}
/* Anchor hover helper */
h2, h3 {
scroll-margin-top: 1.5rem;
}
/* Responsive table wrapping */
.table-scroll {
overflow-x: auto;
margin: 1.25rem 0;
}
.table-scroll table {
margin: 0;
min-width: 480px;
}
/* Badges (mostly emoji-style in the copy, but just in case) */
.badge {
display: inline-block;
padding: 0.1em 0.5em;
border-radius: 999px;
font-size: 0.8em;
font-weight: 600;
background: var(--surface);
border: 1px solid var(--border);
}
/* Footer */
footer.page-footer {
margin-top: 4rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-soft);
font-size: 0.9rem;
color: var(--muted);
}
footer.page-footer p {
margin-bottom: 0.5rem;
}
@media (max-width: 480px) {
body {
font-size: 15.5px;
}
.container {
padding: 1.75rem 1rem 3rem;
}
h1 {
font-size: 1.75rem;
}
h2 {
font-size: 1.3rem;
}
h3 {
font-size: 1.1rem;
}
}
</style>
</head>
<body>
<div class="container">
<header class="page-header">
<div class="eyebrow">mc-webui Guide</div>
<h1>Contact Management in mc-webui</h1>
<p class="lede">Device contacts, the cache layer, the ignored and blocked flags, and the settings that tie everything together — plus how to migrate from the official MeshCore apps.</p>
</header>
<p>This guide explains how contact management works in <strong>mc-webui</strong> and how it differs from the official MeshCore apps for Android and iOS. If you've used the official apps before, some of the concepts introduced here — especially <strong>cache contacts</strong>, the <strong>ignored</strong> flag, and <strong>blocked</strong> contacts — may be new to you. This document walks through all of them, explains why they exist, and provides recommended settings so you can run a tidy contact list without constantly fighting the 350-contact device limit.</p>
<nav class="toc" aria-label="Table of contents">
<h2>Contents</h2>
<ul>
<li><a href="#why-different">Why Contact Management Is Different Here</a></li>
<li><a href="#basics">The Basics: Contacts on Your Device</a></li>
<li><a href="#cache">Cache Contacts</a></li>
<li><a href="#ignored">The Ignored Flag</a></li>
<li><a href="#blocked">Blocked Contacts</a></li>
<li><a href="#moving">Moving Contacts Between Device and Cache</a></li>
<li><a href="#settings">Contact Settings</a></li>
<li><a href="#recommended">Recommended Settings</a></li>
<li><a href="#scenarios">Use Case Scenarios</a></li>
<li><a href="#limit">What to Do When You Hit the 350 Limit</a></li>
<li><a href="#indicators">Visual Indicators in the UI</a></li>
<li><a href="#auto-cleanup">Interaction With Auto-Cleanup</a></li>
<li><a href="#ignore-vs-block">Privacy: Ignore vs Block</a></li>
<li><a href="#faq">FAQ &amp; Migration From the Official Apps</a></li>
</ul>
</nav>
<h2 id="why-different">Why Contact Management Is Different Here</h2>
<p>The official MeshCore applications for Android and iOS keep everything simple: every contact you receive an advertisement from is added to your device, and the only way to get rid of one is to delete it outright. That works fine for light mesh activity, but once you sit in a busy area — or leave your node running for weeks — you quickly hit the hard limit of 350 contacts on a MeshCore device. After that, new contacts either fail to add, or older ones start getting dropped.</p>
<p>mc-webui takes a different approach. It introduces a <strong>cache layer</strong> in its own database that mirrors and extends what lives on the device. This gives you two tiers of storage:</p>
<ul>
<li><strong>Device contacts</strong> — live on the MeshCore hardware. Limited (typically 350), but required for direct messages and repeater management.</li>
<li><strong>Cache contacts</strong> — live only in the mc-webui database. Unlimited in number, unavailable for DM, but still useful for path configuration, the contact map, and <code>@mentions</code>.</li>
</ul>
<p>On top of those two tiers, mc-webui adds two flags — <strong>ignored</strong> and <strong>blocked</strong> — that let you silence contacts without deleting them. Combined with the new <strong>Contacts</strong> settings tab, the goal is to let you keep a large, healthy overview of the mesh around you while only occupying device slots for the contacts you actually talk to.</p>
<h2 id="basics">The Basics: Contacts on Your Device</h2>
<p>Every MeshCore device keeps its own contact list in firmware storage. You can see it in <strong>Contact Management → Existing Contacts</strong> and edit it through the web UI. Each device contact is one of four types:</p>
<div class="table-scroll">
<table>
<thead>
<tr><th>Type</th><th>Meaning</th><th>Typical use</th></tr>
</thead>
<tbody>
<tr><td><strong>COM</strong> (companion)</td><td>Another user's node</td><td>Direct messages</td></tr>
<tr><td><strong>REP</strong> (repeater)</td><td>A repeater node</td><td>Repeater admin commands, path building</td></tr>
<tr><td><strong>ROOM</strong> (room server)</td><td>A room server</td><td>Login + chat in group rooms</td></tr>
<tr><td><strong>SENS</strong> (sensor)</td><td>A sensor node</td><td>Telemetry queries</td></tr>
</tbody>
</table>
</div>
<h3>Why the 350 limit matters</h3>
<p>The MeshCore firmware stores contacts in limited flash memory. Most builds allow up to <strong>350 contacts</strong>; some configurations can go higher (reportedly up to 450), but this is hardware and firmware dependent. Once the device is full, any new advertisement that would create a fresh contact has nowhere to go, and you start losing visibility of the mesh.</p>
<p>mc-webui highlights this pressure with a colored counter above the Existing Contacts list:</p>
<ul>
<li><strong>Green</strong> (&lt; 300 contacts) — plenty of space.</li>
<li><strong>Yellow</strong> (300339 contacts) — warning, start thinking about cleanup.</li>
<li><strong>Red, pulsing</strong> (≥ 340 contacts) — critical, action required.</li>
</ul>
<h3>What device contacts unlock</h3>
<p>Only contacts stored on the device can be used for:</p>
<ul>
<li>Sending <strong>Direct Messages</strong> (DMs).</li>
<li>Running commands against <strong>your own repeaters or room servers</strong>.</li>
<li>Receiving routing/ACK events that rely on the device's live contact table.</li>
</ul>
<p>Everything else — appearing on the map, being available in <code>@mentions</code>, being usable as a path node — does <strong>not</strong> require the contact to be on the device. That's where the cache comes in.</p>
<h2 id="cache">Cache Contacts: Storage Without Device Slots</h2>
<p>A <strong>cache contact</strong> is stored only in the mc-webui SQLite database. It never occupies a slot in the device firmware. In the Existing Contacts view, cache contacts show a <code>Cache</code> badge next to their name.</p>
<p>A contact becomes a cache contact in one of three ways:</p>
<ol>
<li><strong>Automatic caching of adverts.</strong> When Manual approval is enabled (recommended) and a new advert arrives, the contact is written to the cache — not the device. You then decide whether to promote it to the device or leave it in the cache.</li>
<li><strong>Manual "Move to Cache"</strong> from an existing device contact, which removes it from the device but keeps the full record in the database.</li>
<li><strong>Manual entry</strong> via <strong>Add Contact → Paste URI / Scan QR / Manual Entry</strong>, when you choose not to push it to the device.</li>
</ol>
<h3>What cache contacts can do</h3>
<p>Cache contacts keep most of what makes a contact useful:</p>
<ul>
<li><strong>Appear on the Contact Map</strong> — toggle "Cached" on the map to show or hide cache-only contacts.</li>
<li><strong>Participate in <code>@mentions</code></strong> — you can tag cache contacts in channel messages.</li>
<li><strong>Be used as path nodes</strong> in DM Path Management — a repeater you can't fit on the device can still appear in the path-picker when configuring paths to other contacts.</li>
<li><strong>Be promoted to the device at any time</strong> with the "To Device" / "Push to Device" button.</li>
</ul>
<h3>What cache contacts cannot do</h3>
<ul>
<li><strong>No direct messages.</strong> To DM a cache contact you must first push it to the device (which consumes one of the 350 slots).</li>
<li><strong>No live routing data.</strong> The firmware doesn't know about them, so the device cannot ACK, retry, or trace them on your behalf.</li>
</ul>
<p>In short: treat the cache as an address book for people and nodes you want to remember but don't actively talk to. Promote them to the device only when you need to interact directly.</p>
<h2 id="ignored">The Ignored Flag: Silent Mute for New Adverts</h2>
<p>Even with Manual approval enabled, you may want to silence specific nodes entirely — spammers, misconfigured repeaters flooding adverts, or test nodes you simply don't care about. That's what the <strong>ignored</strong> flag is for.</p>
<p>An ignored contact is a special case of a cache contact: the record still exists in the database, but:</p>
<ul>
<li>Their adverts do <strong>not</strong> appear in the Pending Contacts list.</li>
<li>They do <strong>not</strong> trigger browser notifications or FAB badges.</li>
<li>They are hidden by default in the Existing Contacts view.</li>
</ul>
<p>To see ignored contacts, switch the type filter in Existing Contacts to <strong>"Ignored"</strong>. Each ignored row offers a <strong>Restore</strong> action that clears the flag and moves the contact back to the normal list (still cache-only until you push it to the device).</p>
<p>You can ignore a contact in two ways:</p>
<ol>
<li><strong>Manually</strong>, from Pending Contacts or Existing Contacts, via the <strong>Ignore</strong> button.</li>
<li><strong>Automatically</strong>, by enabling "Automatically add new contacts to 'Ignored'" in Settings → Contacts. Every new advert from an unknown node is written to the cache and immediately marked as ignored. See <a href="#settings">Contact Settings</a> below.</li>
</ol>
<p>The ignored flag is <strong>one-way silent</strong>: your node still <em>sees</em> the adverts under the hood, they just don't reach your inbox. This is different from blocking, described next.</p>
<h2 id="blocked">Blocked Contacts: Stopping Unwanted Messages</h2>
<p>Where ignoring handles adverts, <strong>blocking</strong> handles everything else. A blocked contact is another special case of a cache contact:</p>
<ul>
<li>They are treated like an ignored contact for advert notifications (nothing pops up).</li>
<li>On top of that, any <strong>channel messages</strong> they send are dropped server-side — they are not rendered in your chat history, do not contribute to unread counts, and do not appear in search results.</li>
</ul>
<p>Blocking is the right tool for a contact you consider hostile or abusive on public channels, not just noisy. The distinction matters:</p>
<div class="table-scroll">
<table>
<thead>
<tr><th>Behaviour</th><th>Ignored</th><th>Blocked</th></tr>
</thead>
<tbody>
<tr><td>Adverts suppressed</td><td></td><td></td></tr>
<tr><td>Hidden from Existing Contacts by default</td><td></td><td></td></tr>
<tr><td>Their group-chat messages appear in your UI</td><td></td><td>✘ (dropped)</td></tr>
<tr><td>Still searchable via type filter</td><td>"Ignored"</td><td>"Blocked"</td></tr>
<tr><td>Reversible with "Restore"</td><td></td><td></td></tr>
</tbody>
</table>
</div>
<p>To block a contact, click the <strong>Block</strong> button on their contact card. To restore, switch the Existing Contacts type filter to <strong>"Blocked"</strong> and click <strong>Restore</strong>.</p>
<p>A note about DMs: the primary path a stranger takes to reach you is a public-channel message or an advert. If someone is already a DM contact and you want to shut them out, block them <em>and</em> delete the contact from your device — otherwise the device can still accept DMs from them at the firmware level.</p>
<h2 id="moving">Moving Contacts Between Device and Cache</h2>
<p>Contact management is a two-way street. Every contact in your Existing Contacts list has a button that moves it to the other tier:</p>
<ul>
<li><strong>Push to Device</strong> (on a cache contact) — writes the contact to the MeshCore firmware so you can DM them or send repeater commands. This consumes one of the 350 device slots.</li>
<li><strong>Move to Cache</strong> (on a device contact) — removes the contact from the firmware but keeps the full record in the database. Frees up one slot; the contact remains available for <code>@mentions</code>, the map, and path configuration.</li>
</ul>
<p>Both operations are reversible at any time. The database keeps the contact's type, last-seen timestamp, public key, location, and path history intact, so promoting and demoting a contact never loses information.</p>
<p>Typical flow:</p>
<ol>
<li>Advert arrives from a new repeater → cached automatically (Manual approval + Auto-ignore recommended).</li>
<li>You later realize you want to use it as a path hop → leave it in cache, pick it from the Paths picker.</li>
<li>You now want to run admin commands against it → <strong>Push to Device</strong>; it occupies a slot and becomes usable via DM.</li>
<li>You're done configuring it and want to free the slot → <strong>Move to Cache</strong>; it remains in your database and on the map.</li>
</ol>
<h2 id="settings">Contact Settings (Settings → Contacts)</h2>
<p>All contact-wide behaviour lives under <strong>Settings → Contacts</strong> (click the gear icon or use the FAB button). Three toggles control how new contacts reach you:</p>
<h3>1. Manual approval enabled</h3>
<p>When <strong>off</strong> (the default MeshCore behaviour), every new advert is added straight to the device. This is what the Android/iOS apps do.</p>
<p>When <strong>on</strong>, new adverts are written to the mc-webui cache and surface in <strong>Pending Contacts</strong> for review. Nothing reaches the device until you explicitly approve the contact. This is the foundation for every other contact-hygiene feature, because it's only in this mode that mc-webui has a chance to intercept new adverts before they consume a device slot.</p>
<p>The setting is written to the device itself via the <code>set_manual_add_contacts</code> firmware command, so it persists across container restarts.</p>
<h3>2. Suppress new advert notifications</h3>
<p>A purely UI-level toggle. When <strong>on</strong>:</p>
<ul>
<li>The FAB badge over the Contact Management button does not increment on new pending contacts.</li>
<li>No browser notification is raised for new adverts.</li>
<li>The Pending Contacts list itself is unaffected — you'll still see every pending contact listed there when you open the page, with its own counter badge. Nothing is deleted.</li>
</ul>
<p>Use this when you know you'll receive many adverts (busy mesh, holiday weekend, a neighbour's flood-advert test) and you don't want your phone or desktop lighting up every few seconds.</p>
<p>This toggle only matters when Manual approval is on — without manual approval, new adverts bypass the cache entirely, and there's nothing for the UI to suppress. mc-webui disables the checkbox while Manual approval is off.</p>
<h3>3. Automatically add new contacts to "Ignored"</h3>
<p>When <strong>on</strong>, every new advert that would normally land in Pending Contacts is instead written to the cache and immediately marked as ignored. The practical effect:</p>
<ul>
<li>No entry in Pending Contacts.</li>
<li>No notification, no badge.</li>
<li>The contact is fully recorded in the database — visible in Existing Contacts under the "Ignored" filter, on the map (with "Cached" on), and usable as a path hop.</li>
</ul>
<p>This is the closest thing mc-webui offers to "silent observation": your node absorbs the topology of the mesh around it without adding noise to your inbox or burning through device slots. It's also the option that pairs best with a small, hand-curated set of device contacts.</p>
<p>Like suppression, this toggle is gated on Manual approval = on. Without manual approval, new adverts go to the device, not the cache.</p>
<h2 id="recommended">Recommended Settings</h2>
<p>For the vast majority of users running mc-webui long-term, the recommended configuration is:</p>
<div class="table-scroll">
<table>
<thead>
<tr><th>Setting</th><th>Recommended</th></tr>
</thead>
<tbody>
<tr><td>Manual approval enabled</td><td><strong>On</strong></td></tr>
<tr><td>Suppress new advert notifications</td><td><strong>On</strong> (reduces notification pressure) or <strong>Off</strong> (if you like seeing mesh activity)</td></tr>
<tr><td>Automatically add new contacts to "Ignored"</td><td><strong>On</strong></td></tr>
</tbody>
</table>
</div>
<p>This combination gives you:</p>
<ul>
<li><strong>No silent overflow of the 350-contact device limit</strong> — nothing ever lands on the device without your explicit action.</li>
<li><strong>Zero ongoing maintenance</strong> — you don't have to manually approve or ignore every advert.</li>
<li><strong>Full mesh visibility</strong> — every node you've ever heard from remains searchable in Existing Contacts (under "Ignored") and visible on the contact map.</li>
<li><strong>Easy promotion when needed</strong> — any contact can be pushed to the device with one click as soon as you want to DM them or run admin commands.</li>
</ul>
<p>If you prefer to see new adverts as they arrive (for example, to celebrate a new node appearing in your area), turn off "Automatically add new contacts" and "Suppress new advert notifications", but keep Manual approval on. You'll then triage each advert manually from Pending Contacts.</p>
<h2 id="scenarios">Use Case Scenarios</h2>
<h3>Scenario A: The distant repeater you don't own</h3>
<p>You hear a repeater three valleys away. It's useful as a route hop for a DM contact, but you're never going to admin it.</p>
<ul>
<li>Leave the repeater in the cache (or let Auto-ignore put it there).</li>
<li>In the DM Path Management for the contact that needs it, use the <strong>Repeater picker</strong> or <strong>Map picker</strong> to select the cached repeater as a hop.</li>
<li>No device slot used, no clutter in your Existing Contacts list.</li>
</ul>
<h3>Scenario B: A talkative but irrelevant node</h3>
<p>Someone's repeater keeps advertising every few seconds (misconfigured timing). You don't want to look at its adverts, but you also don't want to block legitimate traffic on channels it relays.</p>
<ul>
<li>Click <strong>Ignore</strong> on the contact.</li>
<li>You stop seeing its adverts; its relayed channel messages still flow through normally (repeaters don't send their own channel content).</li>
</ul>
<h3>Scenario C: A spammer on the public channel</h3>
<p>A node keeps flooding <code>#general</code> with unwanted messages.</p>
<ul>
<li>Switch to Existing Contacts, find them, click <strong>Block</strong>.</li>
<li>Their channel messages stop appearing in your chat history.</li>
<li>If they're already a device contact, also click <strong>Delete</strong> on the device side to stop them from opening DMs.</li>
</ul>
<h3>Scenario D: Adding a contact from a QR code at a meetup</h3>
<p>You want to add three friends you met in person. None of them advertise from your location yet.</p>
<ul>
<li>Open <strong>Contact Management → Add Contact</strong>.</li>
<li>Scan each QR code; choose <strong>Add to Device</strong> for the two you'll DM regularly and <strong>Add to Cache</strong> for the third (whose call sign you just want to remember).</li>
<li>Your device slot usage stays low, but all three are retrievable later.</li>
</ul>
<h3>Scenario E: Reclaiming device slots before a trip</h3>
<p>You're about to travel into a dense mesh and expect many new adverts. Your current count is 310 / 350.</p>
<ul>
<li>Open Existing Contacts, filter to <strong>COM</strong>, sort by Last Seen ascending.</li>
<li>For every contact inactive for 30+ days, click <strong>Move to Cache</strong>. You keep the contact record, you free the slot.</li>
<li>Optionally enable Auto-Cleanup to make this routine (see <a href="#auto-cleanup">Interaction With Auto-Cleanup</a>).</li>
</ul>
<h2 id="limit">What to Do When You Hit the 350 Limit</h2>
<p>If the counter in Existing Contacts is red and you can no longer add new contacts to the device, work through this sequence:</p>
<ol>
<li><strong>Stop the bleeding.</strong> If Manual approval is off, turn it on immediately (Settings → Contacts). This prevents any further automatic additions while you clean up.</li>
<li><strong>Enable Auto-ignore temporarily.</strong> New adverts will now skip the Pending list entirely and land silently in the cache as "ignored", so your UI doesn't flood while you work.</li>
<li><strong>Demote inactive contacts.</strong> Switch Existing Contacts to type <strong>COM</strong>, sort by <strong>Last Seen</strong>, and start clicking <strong>Move to Cache</strong> on nodes you haven't heard from in 30+ days. You keep the full record; you just free the device slot.</li>
<li><strong>Use the Cleanup Tool</strong> (bottom of the Contact Management page) for bulk operations:
<ul>
<li>Set <strong>Days of Inactivity</strong> to a reasonable number (30, 60, 90).</li>
<li>Tick the types you want to prune (typically REP and ROOM first, since those recover easily from cache).</li>
<li>Click <strong>Preview Cleanup</strong> and review before confirming.</li>
</ul>
</li>
<li><strong>Consider Auto-Cleanup</strong> for ongoing hygiene. It runs daily at a set hour with the same filter criteria, so the device never creeps back up to the limit.</li>
<li><strong>Keep contacts you actively talk to.</strong> Mark contacts you always want to keep as <strong>Protected</strong> — the cleanup tool (manual and automatic) skips them.</li>
</ol>
<h2 id="indicators">Visual Indicators in the UI</h2>
<p>Contact Management pages use a consistent set of badges and icons so you can tell each contact's status at a glance:</p>
<ul>
<li><strong>Type badge</strong><code>COM</code> (blue), <code>REP</code> (green), <code>ROOM</code> (cyan), <code>SENS</code> (yellow).</li>
<li><strong>Cache badge</strong> — grey pill saying <code>Cache</code>, present only on contacts that live in the database but not on the device.</li>
<li><strong>Ignored / Blocked sections</strong> — selectable via the type filter in Existing Contacts. Contacts with these flags are hidden from the default view and appear only under their respective filter.</li>
<li><strong>Activity dot</strong> on the Last Seen field:
<ul>
<li>🟢 active (&lt; 5 minutes ago)</li>
<li>🟡 recent (&lt; 1 hour ago)</li>
<li>🔴 inactive (&gt; 1 hour ago)</li>
<li>⚫ unknown (no timestamp available)</li>
</ul>
</li>
<li><strong>Contact counter</strong> above the Existing Contacts list — colored green / yellow / red based on how close you are to the 350-slot limit.</li>
<li><strong>FAB badge</strong> on the Contact Management button — shows pending contact count unless Suppress is enabled.</li>
<li><strong>Map markers</strong> — contacts with GPS coordinates appear on the contact map; a "Cached" toggle shows/hides cache-only entries.</li>
</ul>
<p>If you ever need to know whether a contact is on the device or only in the cache, the answer is always one glance at the Cache badge.</p>
<h2 id="auto-cleanup">Interaction With Auto-Cleanup</h2>
<p><strong>Auto-Cleanup</strong> (configured at the bottom of the Contact Management page) operates on <strong>device contacts only</strong>. It never touches cache contacts or the ignored/blocked flags. The reasoning is straightforward:</p>
<ul>
<li>Device contacts consume a limited resource (the 350 slots). Pruning them has a tangible benefit.</li>
<li>Cache contacts are cheap; leaving them around costs nothing.</li>
<li>Ignored / blocked contacts represent explicit user decisions — they shouldn't be deleted silently.</li>
</ul>
<h3>Recommended Auto-Cleanup configuration</h3>
<ul>
<li><strong>Types:</strong> tick <strong>REP</strong>, <strong>ROOM</strong>, and optionally <strong>SENS</strong>. Leave <strong>COM</strong> unticked so that people you've talked to aren't removed automatically.</li>
<li><strong>Date Field:</strong> <strong>Last Advert</strong> (more reliable than Last Modified).</li>
<li><strong>Days of Inactivity:</strong> <strong>30</strong> is a good default; raise it if you have a quiet mesh.</li>
<li><strong>Hour:</strong> pick an hour when the app is not busy (e.g., 03:00 local time).</li>
<li><strong>Protected contacts:</strong> mark any repeater, room, or companion you never want touched as Protected — Auto-Cleanup will skip them.</li>
</ul>
<p>Combined with Auto-ignore, this keeps your device hovering at a healthy contact count without you thinking about it. Adverts you never interact with flow into the ignored cache; device contacts that go quiet for 30+ days roll off automatically; everything you actively use stays put.</p>
<h2 id="ignore-vs-block">Privacy: Ignore vs Block</h2>
<p>It's worth spelling out the distinction one more time, because the vocabulary doesn't exist in the official apps:</p>
<ul>
<li><strong>Ignoring</strong> is about <em>your UI quiet</em>. You no longer see adverts or notifications from that contact. It's the default tool for "I don't care about this node".</li>
<li><strong>Blocking</strong> is about <em>content filtering</em>. In addition to suppressing adverts, it drops their channel messages from your view. It's the tool for "this person's posts are unwelcome on my screen".</li>
</ul>
<p>Neither action is broadcast to the mesh. The node you ignore or block has no way of knowing — from their perspective, nothing changes. Their adverts still travel the network; their messages still reach other users. Only your own node stops rendering them.</p>
<p>Both actions are reversible. Nothing is deleted from the database unless you also hit <strong>Delete</strong> — the flags are just another column in the contact record. If you change your mind, switch the Existing Contacts filter to "Ignored" or "Blocked" and click <strong>Restore</strong>.</p>
<h2 id="faq">FAQ &amp; Migration From the Official Apps</h2>
<p><strong>Q: I've been using the Android app and my device is already at 350 contacts. Where do I start?</strong></p>
<p>Follow <a href="#limit">What to Do When You Hit the 350 Limit</a>. The short version: turn on Manual approval, turn on Auto-ignore, then use Move to Cache (or the Cleanup tool) to demote contacts you don't actively use.</p>
<p><strong>Q: Do cache contacts count against the 350 device limit?</strong></p>
<p>No. The 350-limit applies only to contacts stored on the MeshCore firmware. Cache contacts live in the mc-webui database, which is effectively unlimited.</p>
<p><strong>Q: Will cache contacts sync to my Android/iOS device if I pair it later?</strong></p>
<p>No. The cache is specific to mc-webui. The official MeshCore apps only know about what's on the device. If you connect the same device to the Android app, you'll see only the device contacts, not the cache.</p>
<p><strong>Q: Can I send a DM to a cache contact?</strong></p>
<p>Not directly. Click <strong>Push to Device</strong> first; the contact then occupies a device slot and becomes DM-ready. You can Move it back to the cache when you're done.</p>
<p><strong>Q: What happens if I delete a cache contact?</strong></p>
<p>The full record is removed from the mc-webui database. Ignored / blocked flags, path history, last-seen timestamps, everything goes. The next advert from that node will re-create it — but without any of the history.</p>
<p><strong>Q: I enabled Auto-ignore and now my Pending Contacts list is empty. Is that normal?</strong></p>
<p>Yes. That's exactly what Auto-ignore is designed to do: new adverts bypass the Pending list and land directly in the cache with the ignored flag set. To review recently ignored nodes, open Existing Contacts and switch the type filter to <strong>Ignored</strong>.</p>
<p><strong>Q: Why is Manual approval required for Suppress and Auto-ignore?</strong></p>
<p>Both features operate on the <strong>cache</strong> (pending or newly arrived adverts in the database). Without Manual approval, adverts go straight to the device — there's nothing to suppress or ignore at the cache level. Enabling the toggles in that mode wouldn't change anything, so the UI disables them.</p>
<p><strong>Q: Can I still use <code>@mentions</code> for cache contacts?</strong></p>
<p>Yes. <code>@mentions</code> autocompletes against the full database, including cache-only contacts. This is one of the main reasons the cache exists.</p>
<p><strong>Q: What's the difference between "Last Advert" and "Last Modified" in the Cleanup tool?</strong></p>
<p><strong>Last Advert</strong> is the timestamp of the most recent advertisement received from the contact — it's the most honest signal that a node is still alive. <strong>Last Modified</strong> reflects when anything about the contact record changed (including path updates from your own actions), so it can be misleading. Prefer Last Advert unless you have a specific reason not to.</p>
<p><strong>Q: Can I block a contact by name pattern rather than a specific key?</strong></p>
<p>mc-webui supports a <code>blocked_names</code> table for blocking by name. Currently this isn't exposed in the UI; the individual-contact Block action is the supported path for day-to-day use.</p>
<p><strong>Q: Will the cache grow forever?</strong></p>
<p>Technically yes — every unique advert you've ever received leaves a record. In practice, the database stays small (a record is a few dozen bytes), and you can always run the Cleanup tool against cache entries if you want to prune them manually.</p>
<footer class="page-footer">
<p><strong>Related documentation</strong></p>
<ul>
<li><a href="user-guide.md">User Guide</a> — full feature overview of mc-webui.</li>
<li><a href="rpt-mgmt.md">Repeater Management</a> — how to admin your own repeaters using DM.</li>
<li><a href="dm-retry-logic.md">DM Delivery &amp; Retry Logic</a> — what happens when you send a message and why retries are smart.</li>
<li><a href="meshcore-faq.md">MeshCore FAQ</a> — general questions about MeshCore (not mc-webui-specific).</li>
<li><a href="architecture.md">Architecture</a> — how mc-webui is structured internally, including the contacts data model.</li>
</ul>
<p>Part of the <a href="https://github.com/MarekWo/mc-webui">mc-webui</a> project documentation.</p>
</footer>
</div>
</body>
</html>
+391
View File
@@ -0,0 +1,391 @@
# Contact Management in mc-webui
This guide explains how contact management works in mc-webui and how it differs from the official MeshCore apps for Android and iOS. If you've used the official apps before, some of the concepts introduced here — especially **cache contacts**, the **ignored** flag, and **blocked** contacts — may be new to you. This document walks through all of them, explains why they exist, and provides recommended settings so you can run a tidy contact list without constantly fighting the 350-contact device limit.
## Table of Contents
- [Why Contact Management Is Different Here](#why-contact-management-is-different-here)
- [The Basics: Contacts on Your Device](#the-basics-contacts-on-your-device)
- [Cache Contacts: Storage Without Device Slots](#cache-contacts-storage-without-device-slots)
- [The Ignored Flag: Silent Mute for New Adverts](#the-ignored-flag-silent-mute-for-new-adverts)
- [Blocked Contacts: Stopping Unwanted Messages](#blocked-contacts-stopping-unwanted-messages)
- [Moving Contacts Between Device and Cache](#moving-contacts-between-device-and-cache)
- [Contact Settings (Settings → Contacts)](#contact-settings-settings--contacts)
- [Recommended Settings](#recommended-settings)
- [Use Case Scenarios](#use-case-scenarios)
- [What to Do When You Hit the 350 Limit](#what-to-do-when-you-hit-the-350-limit)
- [Visual Indicators in the UI](#visual-indicators-in-the-ui)
- [Interaction With Auto-Cleanup](#interaction-with-auto-cleanup)
- [Privacy: Ignore vs Block](#privacy-ignore-vs-block)
- [FAQ & Migration From the Official Apps](#faq--migration-from-the-official-apps)
- [Related Documentation](#related-documentation)
---
## Why Contact Management Is Different Here
The official MeshCore applications for Android and iOS keep everything simple: every contact you receive an advertisement from is added to your device, and the only way to get rid of one is to delete it outright. That works fine for light mesh activity, but once you sit in a busy area — or leave your node running for weeks — you quickly hit the hard limit of 350 contacts on a MeshCore device. After that, new contacts either fail to add, or older ones start getting dropped.
mc-webui takes a different approach. It introduces a **cache layer** in its own database that mirrors and extends what lives on the device. This gives you two tiers of storage:
- **Device contacts** — live on the MeshCore hardware. Limited (typically 350), but required for direct messages and repeater management.
- **Cache contacts** — live only in the mc-webui database. Unlimited in number, unavailable for DM, but still useful for path configuration, the contact map, and `@mentions`.
On top of those two tiers, mc-webui adds two flags — **ignored** and **blocked** — that let you silence contacts without deleting them. Combined with the new **Contacts** settings tab, the goal is to let you keep a large, healthy overview of the mesh around you while only occupying device slots for the contacts you actually talk to.
---
## The Basics: Contacts on Your Device
Every MeshCore device keeps its own contact list in firmware storage. You can see it in **Contact Management → Existing Contacts** and edit it through the web UI. Each device contact is one of four types:
| Type | Meaning | Typical use |
|------|---------|-------------|
| **COM** (companion) | Another user's node | Direct messages |
| **REP** (repeater) | A repeater node | Repeater admin commands, path building |
| **ROOM** (room server) | A room server | Login + chat in group rooms |
| **SENS** (sensor) | A sensor node | Telemetry queries |
### Why the 350 limit matters
The MeshCore firmware stores contacts in limited flash memory. Most builds allow up to **350 contacts**; some configurations can go higher (reportedly up to 450), but this is hardware and firmware dependent. Once the device is full, any new advertisement that would create a fresh contact has nowhere to go, and you start losing visibility of the mesh.
mc-webui highlights this pressure with a colored counter above the Existing Contacts list:
- **Green** (< 300 contacts) — plenty of space.
- **Yellow** (300339 contacts) — warning, start thinking about cleanup.
- **Red, pulsing** (≥ 340 contacts) — critical, action required.
### What device contacts unlock
Only contacts stored on the device can be used for:
- Sending **Direct Messages** (DMs).
- Running commands against **your own repeaters or room servers**.
- Receiving routing/ACK events that rely on the device's live contact table.
Everything else — appearing on the map, being available in `@mentions`, being usable as a path node — does **not** require the contact to be on the device. That's where the cache comes in.
---
## Cache Contacts: Storage Without Device Slots
A **cache contact** is stored only in the mc-webui SQLite database. It never occupies a slot in the device firmware. In the Existing Contacts view, cache contacts show a `Cache` badge next to their name.
A contact becomes a cache contact in one of three ways:
1. **Automatic caching of adverts.** When Manual approval is enabled (recommended) and a new advert arrives, the contact is written to the cache — not the device. You then decide whether to promote it to the device or leave it in the cache.
2. **Manual "Move to Cache"** from an existing device contact, which removes it from the device but keeps the full record in the database.
3. **Manual entry** via **Add Contact → Paste URI / Scan QR / Manual Entry**, when you choose not to push it to the device.
### What cache contacts can do
Cache contacts keep most of what makes a contact useful:
- **Appear on the Contact Map** — toggle "Cached" on the map to show or hide cache-only contacts.
- **Participate in `@mentions`** — you can tag cache contacts in channel messages.
- **Be used as path nodes** in DM Path Management — a repeater you can't fit on the device can still appear in the path-picker when configuring paths to other contacts.
- **Be promoted to the device at any time** with the "To Device" / "Push to Device" button.
### What cache contacts cannot do
- **No direct messages.** To DM a cache contact you must first push it to the device (which consumes one of the 350 slots).
- **No live routing data.** The firmware doesn't know about them, so the device cannot ACK, retry, or trace them on your behalf.
In short: treat the cache as an address book for people and nodes you want to remember but don't actively talk to. Promote them to the device only when you need to interact directly.
---
## The Ignored Flag: Silent Mute for New Adverts
Even with Manual approval enabled, you may want to silence specific nodes entirely — spammers, misconfigured repeaters flooding adverts, or test nodes you simply don't care about. That's what the **ignored** flag is for.
An ignored contact is a special case of a cache contact: the record still exists in the database, but:
- Their adverts do **not** appear in the Pending Contacts list.
- They do **not** trigger browser notifications or FAB badges.
- They are hidden by default in the Existing Contacts view.
To see ignored contacts, switch the type filter in Existing Contacts to **"Ignored"**. Each ignored row offers a **Restore** action that clears the flag and moves the contact back to the normal list (still cache-only until you push it to the device).
You can ignore a contact in two ways:
1. **Manually**, from Pending Contacts or Existing Contacts, via the **Ignore** button.
2. **Automatically**, by enabling "Automatically add new contacts to 'Ignored'" in Settings → Contacts. Every new advert from an unknown node is written to the cache and immediately marked as ignored. See [Contact Settings](#contact-settings-settings--contacts) below.
The ignored flag is **one-way silent**: your node still *sees* the adverts under the hood, they just don't reach your inbox. This is different from blocking, described next.
---
## Blocked Contacts: Stopping Unwanted Messages
Where ignoring handles adverts, **blocking** handles everything else. A blocked contact is another special case of a cache contact:
- They are treated like an ignored contact for advert notifications (nothing pops up).
- On top of that, any **channel messages** they send are dropped server-side — they are not rendered in your chat history, do not contribute to unread counts, and do not appear in search results.
Blocking is the right tool for a contact you consider hostile or abusive on public channels, not just noisy. The distinction matters:
| Behaviour | Ignored | Blocked |
|-----------|---------|---------|
| Adverts suppressed | ✔ | ✔ |
| Hidden from Existing Contacts by default | ✔ | ✔ |
| Their group-chat messages appear in your UI | ✔ | ✘ (dropped) |
| Still searchable via type filter | "Ignored" | "Blocked" |
| Reversible with "Restore" | ✔ | ✔ |
To block a contact, click the **Block** button on their contact card. To restore, switch the Existing Contacts type filter to **"Blocked"** and click **Restore**.
A note about DMs: the primary path a stranger takes to reach you is a public-channel message or an advert. If someone is already a DM contact and you want to shut them out, block them *and* delete the contact from your device — otherwise the device can still accept DMs from them at the firmware level.
---
## Moving Contacts Between Device and Cache
Contact management is a two-way street. Every contact in your Existing Contacts list has a button that moves it to the other tier:
- **Push to Device** (on a cache contact) — writes the contact to the MeshCore firmware so you can DM them or send repeater commands. This consumes one of the 350 device slots.
- **Move to Cache** (on a device contact) — removes the contact from the firmware but keeps the full record in the database. Frees up one slot; the contact remains available for `@mentions`, the map, and path configuration.
Both operations are reversible at any time. The database keeps the contact's type, last-seen timestamp, public key, location, and path history intact, so promoting and demoting a contact never loses information.
Typical flow:
1. Advert arrives from a new repeater → cached automatically (Manual approval + Auto-ignore recommended).
2. You later realize you want to use it as a path hop → leave it in cache, pick it from the Paths picker.
3. You now want to run admin commands against it → **Push to Device**; it occupies a slot and becomes usable via DM.
4. You're done configuring it and want to free the slot → **Move to Cache**; it remains in your database and on the map.
---
## Contact Settings (Settings → Contacts)
All contact-wide behaviour lives under **Settings → Contacts** (click the gear icon or use the FAB button). Three toggles control how new contacts reach you:
### 1. Manual approval enabled
When **off** (the default MeshCore behaviour), every new advert is added straight to the device. This is what the Android/iOS apps do.
When **on**, new adverts are written to the mc-webui cache and surface in **Pending Contacts** for review. Nothing reaches the device until you explicitly approve the contact. This is the foundation for every other contact-hygiene feature, because it's only in this mode that mc-webui has a chance to intercept new adverts before they consume a device slot.
The setting is written to the device itself via the `set_manual_add_contacts` firmware command, so it persists across container restarts.
### 2. Suppress new advert notifications
A purely UI-level toggle. When **on**:
- The FAB badge over the Contact Management button does not increment on new pending contacts.
- No browser notification is raised for new adverts.
- The Pending Contacts list itself is unaffected — you'll still see every pending contact listed there when you open the page, with its own counter badge. Nothing is deleted.
Use this when you know you'll receive many adverts (busy mesh, holiday weekend, a neighbour's flood-advert test) and you don't want your phone or desktop lighting up every few seconds.
This toggle only matters when Manual approval is on — without manual approval, new adverts bypass the cache entirely, and there's nothing for the UI to suppress. mc-webui disables the checkbox while Manual approval is off.
### 3. Automatically add new contacts to "Ignored"
When **on**, every new advert that would normally land in Pending Contacts is instead written to the cache and immediately marked as ignored. The practical effect:
- No entry in Pending Contacts.
- No notification, no badge.
- The contact is fully recorded in the database — visible in Existing Contacts under the "Ignored" filter, on the map (with "Cached" on), and usable as a path hop.
This is the closest thing mc-webui offers to "silent observation": your node absorbs the topology of the mesh around it without adding noise to your inbox or burning through device slots. It's also the option that pairs best with a small, hand-curated set of device contacts.
Like suppression, this toggle is gated on Manual approval = on. Without manual approval, new adverts go to the device, not the cache.
---
## Recommended Settings
For the vast majority of users running mc-webui long-term, the recommended configuration is:
| Setting | Recommended |
|---------|-------------|
| Manual approval enabled | **On** |
| Suppress new advert notifications | **On** (reduces notification pressure) or **Off** (if you like seeing mesh activity) |
| Automatically add new contacts to "Ignored" | **On** |
This combination gives you:
- **No silent overflow of the 350-contact device limit** — nothing ever lands on the device without your explicit action.
- **Zero ongoing maintenance** — you don't have to manually approve or ignore every advert.
- **Full mesh visibility** — every node you've ever heard from remains searchable in Existing Contacts (under "Ignored") and visible on the contact map.
- **Easy promotion when needed** — any contact can be pushed to the device with one click as soon as you want to DM them or run admin commands.
If you prefer to see new adverts as they arrive (for example, to celebrate a new node appearing in your area), turn off "Automatically add new contacts" and "Suppress new advert notifications", but keep Manual approval on. You'll then triage each advert manually from Pending Contacts.
---
## Use Case Scenarios
### Scenario A: The distant repeater you don't own
You hear a repeater three valleys away. It's useful as a route hop for a DM contact, but you're never going to admin it.
- Leave the repeater in the cache (or let Auto-ignore put it there).
- In the DM Path Management for the contact that needs it, use the **Repeater picker** or **Map picker** to select the cached repeater as a hop.
- No device slot used, no clutter in your Existing Contacts list.
### Scenario B: A talkative but irrelevant node
Someone's repeater keeps advertising every few seconds (misconfigured timing). You don't want to look at its adverts, but you also don't want to block legitimate traffic on channels it relays.
- Click **Ignore** on the contact.
- You stop seeing its adverts; its relayed channel messages still flow through normally (repeaters don't send their own channel content).
### Scenario C: A spammer on the public channel
A node keeps flooding `#general` with unwanted messages.
- Switch to Existing Contacts, find them, click **Block**.
- Their channel messages stop appearing in your chat history.
- If they're already a device contact, also click **Delete** on the device side to stop them from opening DMs.
### Scenario D: Adding a contact from a QR code at a meetup
You want to add three friends you met in person. None of them advertise from your location yet.
- Open **Contact Management → Add Contact**.
- Scan each QR code; choose **Add to Device** for the two you'll DM regularly and **Add to Cache** for the third (whose call sign you just want to remember).
- Your device slot usage stays low, but all three are retrievable later.
### Scenario E: Reclaiming device slots before a trip
You're about to travel into a dense mesh and expect many new adverts. Your current count is 310 / 350.
- Open Existing Contacts, filter to **COM**, sort by Last Seen ascending.
- For every contact inactive for 30+ days, click **Move to Cache**. You keep the contact record, you free the slot.
- Optionally enable Auto-Cleanup to make this routine (see [Interaction With Auto-Cleanup](#interaction-with-auto-cleanup)).
---
## What to Do When You Hit the 350 Limit
If the counter in Existing Contacts is red and you can no longer add new contacts to the device, work through this sequence:
1. **Stop the bleeding.** If Manual approval is off, turn it on immediately (Settings → Contacts). This prevents any further automatic additions while you clean up.
2. **Enable Auto-ignore temporarily.** New adverts will now skip the Pending list entirely and land silently in the cache as "ignored", so your UI doesn't flood while you work.
3. **Demote inactive contacts.** Switch Existing Contacts to type **COM**, sort by **Last Seen**, and start clicking **Move to Cache** on nodes you haven't heard from in 30+ days. You keep the full record; you just free the device slot.
4. **Use the Cleanup Tool** (bottom of the Contact Management page) for bulk operations:
- Set **Days of Inactivity** to a reasonable number (30, 60, 90).
- Tick the types you want to prune (typically REP and ROOM first, since those recover easily from cache).
- Click **Preview Cleanup** and review before confirming.
5. **Consider Auto-Cleanup** for ongoing hygiene. It runs daily at a set hour with the same filter criteria, so the device never creeps back up to the limit.
6. **Keep contacts you actively talk to.** Mark contacts you always want to keep as **Protected** — the cleanup tool (manual and automatic) skips them.
---
## Visual Indicators in the UI
Contact Management pages use a consistent set of badges and icons so you can tell each contact's status at a glance:
- **Type badge**`COM` (blue), `REP` (green), `ROOM` (cyan), `SENS` (yellow).
- **Cache badge** — grey pill saying `Cache`, present only on contacts that live in the database but not on the device.
- **Ignored / Blocked sections** — selectable via the type filter in Existing Contacts. Contacts with these flags are hidden from the default view and appear only under their respective filter.
- **Activity dot** on the Last Seen field:
- 🟢 active (< 5 minutes ago)
- 🟡 recent (< 1 hour ago)
- 🔴 inactive (> 1 hour ago)
- ⚫ unknown (no timestamp available)
- **Contact counter** above the Existing Contacts list — colored green / yellow / red based on how close you are to the 350-slot limit.
- **FAB badge** on the Contact Management button — shows pending contact count unless Suppress is enabled.
- **Map markers** — contacts with GPS coordinates appear on the contact map; a "Cached" toggle shows/hides cache-only entries.
If you ever need to know whether a contact is on the device or only in the cache, the answer is always one glance at the Cache badge.
---
## Interaction With Auto-Cleanup
**Auto-Cleanup** (configured at the bottom of the Contact Management page) operates on **device contacts only**. It never touches cache contacts or the ignored/blocked flags. The reasoning is straightforward:
- Device contacts consume a limited resource (the 350 slots). Pruning them has a tangible benefit.
- Cache contacts are cheap; leaving them around costs nothing.
- Ignored / blocked contacts represent explicit user decisions — they shouldn't be deleted silently.
### Recommended Auto-Cleanup configuration
- **Types:** tick **REP**, **ROOM**, and optionally **SENS**. Leave **COM** unticked so that people you've talked to aren't removed automatically.
- **Date Field:** **Last Advert** (more reliable than Last Modified).
- **Days of Inactivity:** **30** is a good default; raise it if you have a quiet mesh.
- **Hour:** pick an hour when the app is not busy (e.g., 03:00 local time).
- **Protected contacts:** mark any repeater, room, or companion you never want touched as Protected — Auto-Cleanup will skip them.
Combined with Auto-ignore, this keeps your device hovering at a healthy contact count without you thinking about it. Adverts you never interact with flow into the ignored cache; device contacts that go quiet for 30+ days roll off automatically; everything you actively use stays put.
---
## Privacy: Ignore vs Block
It's worth spelling out the distinction one more time, because the vocabulary doesn't exist in the official apps:
- **Ignoring** is about *your UI quiet*. You no longer see adverts or notifications from that contact. It's the default tool for "I don't care about this node".
- **Blocking** is about *content filtering*. In addition to suppressing adverts, it drops their channel messages from your view. It's the tool for "this person's posts are unwelcome on my screen".
Neither action is broadcast to the mesh. The node you ignore or block has no way of knowing — from their perspective, nothing changes. Their adverts still travel the network; their messages still reach other users. Only your own node stops rendering them.
Both actions are reversible. Nothing is deleted from the database unless you also hit **Delete** — the flags are just another column in the contact record. If you change your mind, switch the Existing Contacts filter to "Ignored" or "Blocked" and click **Restore**.
---
## FAQ & Migration From the Official Apps
**Q: I've been using the Android app and my device is already at 350 contacts. Where do I start?**
Follow [What to Do When You Hit the 350 Limit](#what-to-do-when-you-hit-the-350-limit). The short version: turn on Manual approval, turn on Auto-ignore, then use Move to Cache (or the Cleanup tool) to demote contacts you don't actively use.
**Q: Do cache contacts count against the 350 device limit?**
No. The 350-limit applies only to contacts stored on the MeshCore firmware. Cache contacts live in the mc-webui database, which is effectively unlimited.
**Q: Will cache contacts sync to my Android/iOS device if I pair it later?**
No. The cache is specific to mc-webui. The official MeshCore apps only know about what's on the device. If you connect the same device to the Android app, you'll see only the device contacts, not the cache.
**Q: Can I send a DM to a cache contact?**
Not directly. Click **Push to Device** first; the contact then occupies a device slot and becomes DM-ready. You can Move it back to the cache when you're done.
**Q: What happens if I delete a cache contact?**
The full record is removed from the mc-webui database. Ignored / blocked flags, path history, last-seen timestamps, everything goes. The next advert from that node will re-create it — but without any of the history.
**Q: I enabled Auto-ignore and now my Pending Contacts list is empty. Is that normal?**
Yes. That's exactly what Auto-ignore is designed to do: new adverts bypass the Pending list and land directly in the cache with the ignored flag set. To review recently ignored nodes, open Existing Contacts and switch the type filter to **Ignored**.
**Q: Why is Manual approval required for Suppress and Auto-ignore?**
Both features operate on the **cache** (pending or newly arrived adverts in the database). Without Manual approval, adverts go straight to the device — there's nothing to suppress or ignore at the cache level. Enabling the toggles in that mode wouldn't change anything, so the UI disables them.
**Q: Can I still use `@mentions` for cache contacts?**
Yes. `@mentions` autocompletes against the full database, including cache-only contacts. This is one of the main reasons the cache exists.
**Q: What's the difference between "Last Advert" and "Last Modified" in the Cleanup tool?**
**Last Advert** is the timestamp of the most recent advertisement received from the contact — it's the most honest signal that a node is still alive. **Last Modified** reflects when anything about the contact record changed (including path updates from your own actions), so it can be misleading. Prefer Last Advert unless you have a specific reason not to.
**Q: Can I block a contact by name pattern rather than a specific key?**
mc-webui supports a `blocked_names` table for blocking by name. Currently this isn't exposed in the UI; the individual-contact Block action is the supported path for day-to-day use.
**Q: Will the cache grow forever?**
Technically yes — every unique advert you've ever received leaves a record. In practice, the database stays small (a record is a few dozen bytes), and you can always run the Cleanup tool against cache entries if you want to prune them manually.
---
## Related Documentation
- [User Guide](user-guide.md) — full feature overview of mc-webui.
- [Repeater Management](rpt-mgmt.md) — how to admin your own repeaters using DM.
- [DM Delivery & Retry Logic](dm-retry-logic.md) — what happens when you send a message and why retries are smart.
- [MeshCore FAQ](meshcore-faq.md) — general questions about MeshCore (not mc-webui-specific).
- [Architecture](architecture.md) — how mc-webui is structured internally, including the contacts data model.
+170
View File
@@ -0,0 +1,170 @@
# DM Delivery & Retry Logic
This document explains how mc-webui delivers direct messages (DMs) and retries them when the recipient doesn't confirm receipt.
## How delivery confirmation works
When you send a DM, the MeshCore device transmits it over radio and waits for an **ACK** (acknowledgment) from the recipient's device. If no ACK arrives within the expected time, the app retries the message — possibly changing the route to improve the chances of delivery.
The progress is shown in real time next to the message bubble: **"Attempt 3/11"**, and when delivery is confirmed, the route used is displayed (e.g. `5E->05->58->D1`).
## Settings (Settings > Messages)
All retry parameters are configurable in **Settings > Messages**:
| Setting | Section | Default | Description |
|---|---|---|---|
| **Direct retries** | When path is known | 3 | How many times to resend via the current route before trying alternatives |
| **Flood retries** | When path is known | 1 | How many flood attempts after direct retries (when no extra paths are configured) |
| **Interval (s)** | When path is known | 30 | Minimum seconds between direct retry attempts |
| **Max retries** | When no path | 3 | How many flood retry attempts |
| **Interval (s)** | When no path | 60 | Minimum seconds between flood retry attempts |
| **Grace period (s)** | Other | 60 | After all retries fail, keep listening for a late ACK this long before giving up |
> **Note:** "Retries" means attempts *after* the initial send. So "3 retries" = 4 total attempts (1 initial + 3 retries).
### Why the actual wait time can be longer than the configured interval
The device firmware reports a **suggested timeout** for each message — this is its best estimate of how long the ACK might take, based on the route length, signal quality, and network conditions.
The actual wait between attempts is:
```
actual_wait = max(firmware_suggested_timeout * 1.2, configured_interval)
```
In other words: the configured interval is a **minimum floor**, not a fixed value. If the firmware says "this route needs ~32 seconds for an ACK to come back", the app will wait at least `32 * 1.2 = 38 seconds` — even if your interval is set to 15s. This prevents premature retries that would waste airtime.
You can see the actual wait in the System Log:
```
DM retry task started: dm_id=2107, scenario=S4, ..., wait=38s
```
## The four delivery scenarios
The app picks one of four strategies depending on two factors:
1. **Does the contact have a known route?** (visible as a path in Contact Info, e.g. `5E->05->58->D1`)
2. **Are there extra configured paths?** (the "Paths" list in Contact Info)
| | No configured paths | Has configured paths |
|---|---|---|
| **No known route** | Scenario 1: Flood only | Scenario 3: Flood, then path rotation |
| **Has known route** | Scenario 2: Direct, then flood | Scenario 4: Direct, path rotation, then flood |
Each scenario is also affected by the **Keep path** toggle in the contact's DM window.
---
### Scenario 1: No route, no configured paths
The simplest case. The app has no route information at all — it can only send via **flood** (broadcast to the entire mesh network).
**Steps:**
1. Send message (flood)
2. Wait for ACK
3. If no ACK: retry up to **Max retries** times, waiting **Interval (flood)** between attempts
**Total attempts:** 1 + Max retries (default: 4)
The **Keep path** toggle has no effect here — there is no path to keep.
---
### Scenario 2: Has route, no configured paths
The contact has a known route but no extra paths are configured.
**Steps:**
1. Send message via the known route (direct)
2. Wait for ACK
3. If no ACK: retry up to **Direct retries** times via the same route, waiting **Interval (direct)** between attempts
4. If still no ACK and **Keep path is OFF**: reset to flood and retry **Flood retries** times (from the "When path is known" section)
5. If **Keep path is ON**: stop after direct retries (no flood fallback)
**Total attempts:**
- Keep path OFF: 1 + Direct retries + Flood retries (default: 5)
- Keep path ON: 1 + Direct retries (default: 4)
---
### Scenario 3: No route, has configured paths
The contact has no current route, but you've saved one or more paths in Contact Info. The app first tries flood (hoping to discover a fresh route), then rotates through your configured paths.
**Steps:**
1. Send via flood
2. Wait for ACK
3. If no ACK: retry via flood up to **Max retries** times
4. If still no ACK: switch to configured paths, trying each one in order (primary path first, then the rest by sort order)
5. For each configured path: retry up to **Direct retries** times (minimum 1)
6. After finishing (success or failure): restore the primary path on the device
**Total attempts:** 1 + Max retries + (number of paths * Direct retries) (default with 3 paths: 13)
The **Keep path** toggle has no effect here — there is no current path to protect.
---
### Scenario 4: Has route + has configured paths
The most complete scenario. The contact has both a current route and extra configured paths.
**Steps:**
1. Send message via the current route (direct)
2. Wait for ACK
3. If no ACK: retry up to **Direct retries** times via the same route
4. If still no ACK: rotate through configured paths (skipping any path that matches the already-tried current route)
5. For each configured path: retry up to **Direct retries** times (minimum 1)
6. If still no ACK and **Keep path is OFF**: reset to flood and retry **Max retries** times (from the "When no path" section)
7. If **Keep path is ON**: stop after path rotation (no flood fallback)
8. After finishing (success or failure): restore the primary path on the device
**Total attempts:**
- Keep path OFF: 1 + Direct retries + (unique paths * Direct retries) + Max retries
- Keep path ON: 1 + Direct retries + (unique paths * Direct retries)
> **Deduplication:** If one of your configured paths is identical to the contact's current route, it is skipped during rotation to avoid trying the same route twice.
---
## After all retries fail
When all attempts are exhausted:
- The message gets a **failed** icon (X)
- The app continues to listen for a late ACK during the **Grace period** (default: 60s)
- If a late ACK arrives during the grace period, the message is still marked as delivered
- In scenarios with configured paths: the **primary path** is restored on the device, regardless of outcome
## Monitoring in System Log
The retry process logs to the `device_manager` module. At **INFO** level you'll see the key events:
```
DM retry task started: dm_id=2107, scenario=S4_DIRECT_SD_FLOOD,
configured_paths=4, no_auto_flood=True, max_attempts=21, wait=38s
DM retry: direct retries exhausted, rotating through configured paths
DM retry: switched to path 'Device path' (5e0558d1)
DM retry: switched to path 'Device path' (5e9005a68a4bf0d1)
DM retry: all paths exhausted, falling back to FLOOD
DM retry exhausted (21 total attempts, scenario=S4) for dm_id=2107
```
At **DEBUG** level (for advanced troubleshooting) you'll also see each individual attempt:
```
DM retry: waiting 38s for initial ACK c53e8870...
DM retry attempt #1: sending dm_id=2107
DM retry #1: waiting 38s for ACK a1b2c3d4...
DM retry: skipping path 'Primary' (5e0558d1) — matches current device path
```
### Scenario names in logs
| Log name | Meaning |
|---|---|
| `S1_FLOOD` | Scenario 1: flood only |
| `S2_DIRECT_FLOOD` | Scenario 2: direct + flood fallback |
| `S3_FLOOD_SD` | Scenario 3: flood + path rotation |
| `S4_DIRECT_SD_FLOOD` | Scenario 4: direct + path rotation + flood |
+127
View File
@@ -0,0 +1,127 @@
# How to Pair MeshCore/Heltec Devices via Bluetooth on Linux
Pairing Bluetooth Low Energy (BLE) devices like Heltec (running MeshCore or Meshtastic) with a headless Linux server can sometimes be tricky due to security negotiations. Follow this guide to ensure a stable and successful connection for the `mc-webui` application.
## Prerequisites: Device Preparation
Before touching the Linux terminal, you must configure your MeshCore device to use a fixed PIN. This prevents authentication timeouts and makes headless pairing much easier.
1. Connect to your MeshCore device via the mobile app or web interface.
2. Go to the **Bluetooth Settings**.
3. Set the pairing mode to use a **Fixed PIN** (Passkey).
4. Enter a memorable 6-digit PIN (e.g., `123456`).
5. Save the configuration and let the device reboot.
---
## Step 1: Linux Server Preparation
Linux's default Bluetooth stack (BlueZ) needs to be optimized for Bluetooth Low Energy (BLE).
1. Edit the main Bluetooth configuration file:
```bash
sudo nano /etc/bluetooth/main.conf
```
2. Find the `[General]` section and add or modify the following lines to force LE mode and speed up connections:
```ini
ControllerMode = le
FastConnectable = true
```
3. Save the file and restart the Bluetooth service:
```bash
sudo systemctl restart bluetooth
```
*Note for Proxmox/VM Users:* If you are passing a physical USB Bluetooth dongle to a Virtual Machine, **do not use USB 3.0 passthrough**. It causes packet drops and timeouts (`Opcode failed` errors). Always force USB 2.0. Example Proxmox command:
`qm set <VMID> -usb0 host=<VENDOR_ID>:<PRODUCT_ID>,usb3=0`
---
## Step 2: The Pairing Process
Use the built-in `bluetoothctl` tool to discover, pair, and trust your device.
1. Open the Bluetooth control utility:
```bash
bluetoothctl
```
2. Enable the keyboard display agent (this tells Linux to ask you for the PIN):
```text
[bluetooth]# agent KeyboardDisplay
[bluetooth]# default-agent
```
3. Turn on the Bluetooth scan to find your device:
```text
[bluetooth]# scan le
```
4. Wait until your device appears in the list and note its MAC address (e.g., `AC:A7:04:08:66:A1 MeshCore-demo mc-webui`).
5. Initiate the pairing process using the MAC address:
```text
[bluetooth]# pair AC:A7:04:08:66:A1
```
6. The terminal will prompt you for the passkey:
`[agent] Enter passkey (number in 0-999999):`
Enter the **Fixed PIN** you configured earlier (e.g., `123456`) and press Enter.
7. You should see `Pairing successful`.
---
## Step 3: Trusting the Device
This is the most crucial step. You must "trust" the device so that `mc-webui` can automatically connect to it in the future without requiring the PIN again.
1. In the `bluetoothctl` prompt, type:
```text
[bluetooth]# trust AC:A7:04:08:66:A1
```
2. You should see `trust succeeded`.
3. You can now safely exit the utility:
```text
[bluetooth]# exit
```
Your MeshCore device is now permanently paired, trusted, and ready to communicate with the `mc-webui` server!
---
## Troubleshooting
### Checking BLE Connection Status
**Do NOT use `bluetoothctl info <MAC>` to check connection state** — it auto-connects to trusted devices, which steals the connection from the `mc-webui` container.
Instead, use `hcitool` which only reads status without triggering a connection:
```bash
hcitool con
```
Expected output when connected:
```
Connections:
< LE AC:A7:04:08:66:A1 handle 65 state 1 lm CENTRAL AUTH ENCRYPT
```
### Container Can't Connect (connection loop)
If `mc-webui` is stuck in a "Failed to connect to device / Retrying..." loop:
1. Check if something else holds the connection:
```bash
hcitool con
```
2. If a connection exists, disconnect it:
```bash
bluetoothctl disconnect AC:A7:04:08:66:A1
```
3. Restart the container:
```bash
cd ~/mc-webui && docker compose restart mc-webui
```
4. Verify (wait ~15s for retry):
```bash
hcitool con
curl -s http://localhost:5000/api/status | python3 -m json.tool
```
### Only One BLE Client at a Time
A BLE peripheral can only be connected to one central at a time. If `bluetoothctl`, another app, or a mobile phone is connected, the container will fail. Always disconnect other clients before starting `mc-webui`.
+10
View File
@@ -130,6 +130,16 @@ This can happen after a power failure during OTA update, flash memory corruption
---
### BLE Connection Issues
If using Bluetooth Low Energy (BLE) transport, see the dedicated [Bluetooth Pairing Guide](meshcore_bluetooth_pairing.md) for setup and troubleshooting, including:
- Host preparation (BlueZ configuration, `ControllerMode = le`)
- Pairing with fixed PIN
- Trusting the device for automatic reconnection
- Diagnosing connection loops and stale BlueZ connections
---
### Contact Management Issues
**Check logs:**
+238 -12
View File
@@ -6,14 +6,18 @@ This guide covers all features and functionality of mc-webui. For installation i
- [Viewing Messages](#viewing-messages)
- [Managing Channels](#managing-channels)
- [Region Scopes](#region-scopes)
- [Message Archives](#message-archives)
- [Sending Messages](#sending-messages)
- [Message Content Features](#message-content-features)
- [Direct Messages (DM)](#direct-messages-dm)
- [Global Search](#global-search)
- [Contact Management](#contact-management)
- [Adding Contacts](#adding-contacts)
- [DM Path Management](#dm-path-management)
- [Interactive Console](#interactive-console)
- [Device Dashboard](#device-dashboard)
- [Quick-Access FAB Buttons](#quick-access-fab-buttons)
- [Settings](#settings)
- [System Log](#system-log)
- [Database Backup](#database-backup)
@@ -35,6 +39,8 @@ The main page displays chat history from the currently selected channel. The app
By default, the live view shows messages from the last 7 days. Older messages are automatically archived and can be accessed via the date selector.
On wide screens (tablets/desktops), a sidebar shows the channel list on the left side for quick switching.
---
## Managing Channels
@@ -81,7 +87,47 @@ Access channel management:
### Switching Channels
Use the channel selector dropdown in the navbar to switch between channels. Your selection is remembered between sessions.
On narrow screens, tap the channel selector in the navbar to open a searchable picker:
- Type to filter channels by name (case-insensitive substring match)
- Each row shows the last message time (HH:MM today, DD.MM.YYYY otherwise) and a one-line preview of the latest message
- Use ↑/↓ arrows + Enter to select with the keyboard, Esc to close
- Unread channels show a red badge with the count
On wide screens (tablets/desktops), use the channel sidebar on the left. Each sidebar entry shows the channel name, time of the last message, unread badge, and a two-line message preview.
Your selection is remembered between sessions.
---
## Region Scopes
Region scopes (called "flood scopes" in the firmware) tag your channel messages with a 16-byte key derived from a region name (e.g. `pl`, `pl-ma`, `krakow`). Repeaters that support region filtering will only forward a message if their list of allowed regions includes the tag — so you can keep regional traffic out of distant nodes and reduce airtime on the wider mesh.
Standardised region names are listed at <https://regions.meshcore.nz/>.
### Region Registry
Manage the regions you use in **Settings → Regions**:
1. Open Settings (cog icon in the menu or FAB)
2. Switch to the **Regions** tab
3. Type a region name (max 30 bytes; allowed: letters, digits, `-`, `$`, `#`) and click **Add**
4. Pick the **default region** via the radio button — this is the scope used for any channel without an explicit override (and is also pushed to the firmware as its persistent default)
5. Select the **None** radio to clear the firmware default and rely on the firmware's built-in fallback
6. Click the trash icon to delete a region (channels using it revert to "no scope")
> **Firmware version:** Setting the persistent firmware default scope (CMD 63) requires firmware **v1.15 or newer**. On older firmware mc-webui still saves your choice locally but shows a warning that the firmware default was not updated.
### Per-Channel Region
Each channel can have its own region scope, overriding the default:
- **Status bar pill** — Below the channel chat, a pill shows the active channel's region (e.g. "📍 pl-ma"). Tap it to change the region or set one.
- **Manage Channels modal** — Each channel row has a 📍 pin button and an inline region badge. Click the pin button to open the picker.
- In the picker, choose a region from the list, or pick **None — use firmware default** to remove the override.
When you send a message on a channel, mc-webui pushes the channel's scope key to the device just before sending. Channels without an explicit region send with an empty key so a previously-set scope from another channel doesn't leak.
---
@@ -173,16 +219,27 @@ Access the Direct Messages feature:
### Message Status Indicators
- ✓ **Delivered** (green checkmark) - Recipient confirmed receipt (ACK). Tap/hover for SNR and route details
- ✓ **Delivered** (green checkmark) - Recipient confirmed receipt (ACK). Tap/hover for SNR, route, and hop count details
- ✗ **Failed** (red X) - All retry attempts exhausted with no ACK
- ? **Unknown** (gray question mark) - No ACK received. Message may still have been delivered — ACK packets are often lost over multi-hop routes. Tap the icon for details
- ⏳ **Pending** (clock icon, yellow) - Message sent, awaiting delivery confirmation
### Real-time Delivery Progress
While a message is being retried, the UI shows a live counter below the message bubble (e.g., "Attempt 3/11") starting from "Attempt 1/..." immediately after sending. When delivery is confirmed (or fails), delivery details (attempt, route used) appear instantly without needing to close and reopen the conversation.
The route used (e.g., `5E->05->58->D1`) can be clicked to show a popup with full path details. Tapping individual route entries in the popup copies the path to the clipboard in comma-separated format (e.g. `5E,32,0D,8C`).
### DM Notifications
- The bell icon shows a secondary green badge for unread DMs
- Each conversation shows unread indicator (*) in the dropdown
- DM badge in the menu shows total unread DM count
### Desktop Sidebar
On wide screens (tablets/desktops), the DM page shows a sidebar with the contact list on the left side, making it easy to switch between conversations without using the dropdown selector.
---
## Global Search
@@ -210,6 +267,8 @@ Access the Contact Management feature to control who can connect to your node:
2. Select "Contact Management" from the menu
3. Opens the contact management page
> **In depth:** For a full conceptual walkthrough of device contacts, the cache layer, the ignored/blocked flags, and recommended settings — especially if you're coming from the official Android/iOS apps — see the dedicated [Contact Management Guide](contact-management.md).
### Manual Contact Approval
By default, new contacts attempting to connect are automatically added to your contacts list. You can enable manual approval to control who can communicate with your node.
@@ -345,6 +404,72 @@ You can schedule automatic cleanup to run daily at a specified hour:
---
## Adding Contacts
Add new contacts to your device from the Contact Management page:
1. Click the "Add Contact" button at the top of the Contact Management page
2. Opens a dedicated page with three methods:
### Paste URI
1. Paste a MeshCore contact URI (`meshcore://...`) into the text field
2. The contact details (name, public key, type) are automatically parsed and previewed
3. Click "Add to Device" to add the contact
### Scan QR Code
1. Click "Scan QR" to open the camera
2. Point at a MeshCore QR code (from another user's Share tab)
3. The URI is decoded and contact details are previewed
4. Click "Add to Device" to add the contact
### Manual Entry
1. Enter the contact's public key (64 hex characters)
2. Optionally enter name, type (COM/REP/ROOM/SENS), and location
3. Click "Add to Device"
### Cache vs Device Contacts
- **Device contacts** are stored on the MeshCore hardware (limit: 350)
- **Cache contacts** are stored only in the database (unlimited)
- Use "Push to Device" to promote a cache contact to the device
- Use "Move to Cache" to free a device slot while keeping the contact in the database
---
## DM Path Management
Configure message routing paths for individual contacts:
1. Open a DM conversation
2. Click the contact info icon next to the contact name
3. In the Contact Info modal, navigate to the "Paths" section
### Path Configuration
- **Add Path** - Add a repeater to the routing path using:
- **Repeater picker** - Browse available repeaters by name or ID
- **Map picker** - Select repeaters from a map view showing their GPS locations
- **Import current path** - Import the path currently stored on the device
- **Reorder** - Drag paths to change priority (starred path is used first)
- **Star** - Mark a preferred primary path (used first in retry rotation)
- **Delete** - Remove individual paths
### Keep Path Toggle
- Enable "Keep path" to prevent the device from automatically switching to FLOOD routing
- When enabled, the device will always use the configured DIRECT path(s)
- Useful when you know the optimal route and don't want the device to override it
### Path Operations
- **Reset to FLOOD** - Clear all paths and switch to FLOOD routing
- **Clear Paths** - Remove all configured paths without changing routing mode
---
## Interactive Console
Access the interactive console for direct MeshCore command execution:
@@ -414,31 +539,129 @@ Shows live device statistics:
- Message counters (sent, received, forwarded)
- Current airtime usage
### Share Tab
Share your device contact with others:
- **QR Code** - Scannable QR code containing your contact URI
- **URI** - Copyable `meshcore://` URI that others can paste into their Add Contact page
---
## Quick-Access FAB Buttons
A set of Floating Action Buttons (FAB) is pinned to the main chat and DM pages for one-tap access to common features:
- **Filter** (funnel icon) - Open the message filter bar
- **Search** (magnifier icon) - Open global full-text search (main chat only)
- **Direct Messages** (envelope icon) - Jump to the DM page (main chat only)
- **Contact Management** (person icon) - Open the Contact Management page (main chat only)
- **Settings** (gear icon) - Open the Settings modal
### Drag to Reposition
Press and hold the small toggle button (chevron) and drag the FAB cluster to any corner of the screen. The position is saved to browser local storage per page (main chat and DM each have their own).
### Collapse / Expand
Tap the toggle button (short click, no drag) to hide or show the rest of the FAB cluster. Only the toggle remains visible when collapsed. The collapsed state is shared between main chat and DM views and persists across page loads.
### Customization
Open Settings → **Appearance** tab to adjust:
- **Button size** - 28 to 72 pixels (default: 56)
- **Spacing** - 2 to 24 pixels between buttons (default: 12)
- **Reset position** - Reset both main chat and DM FAB positions to their defaults
Size and spacing are applied live as you move the sliders.
---
## Settings
Access the Settings modal to configure application behavior:
1. Click the menu icon (☰) in the navbar
1. Click the menu icon (☰) in the navbar (or tap the gear FAB button)
2. Select "Settings" from the menu
### DM Retry Settings
The modal is organized into tabs: **Device**, **Messages**, **Group Chat**, **Interface**, **Appearance**, **Contacts**, and **Regions**. A global **Close** button at the bottom of the modal dismisses Settings from any tab.
Configure how direct messages are retried when delivery is not confirmed:
- **Retry count** - Number of retry attempts (includes initial send)
- **Retry interval** - Seconds between retries
- **Flood fallback attempt** - After which attempt to switch from DIRECT to FLOOD routing
- **Grace period** - Seconds to wait for late ACKs after all retries complete
### Device Tab
### Quote Settings
Configure your MeshCore device directly from the web UI. Split into two sub-tabs:
**Public Info:**
- **Name** - Device name (up to 32 characters)
- **Latitude / Longitude** - GPS coordinates. Click the map pin button to open a map picker and click anywhere on the map to select coordinates
- **Share position in advert** - Toggle whether GPS coordinates are broadcast in advertisement frames (maps to `advert_loc_policy`)
- **Path hash mode** - Bytes per hop in routing paths (1 byte / 2 bytes / 3 bytes). 1 byte produces the shortest paths but more hash collisions; 3 bytes produces the longest paths with the fewest collisions. Default is 1 byte. Requires firmware v1.14 or newer for 2/3 byte modes.
**Radio Settings:**
- **Load preset** - Apply a regional preset (Australia, EU/UK, USA/Canada, New Zealand, Switzerland, Vietnam, and more). Selecting a preset fills in frequency, bandwidth, spreading factor, and coding rate
- **Frequency (MHz)** - LoRa carrier frequency
- **Bandwidth (kHz)** - 7.8 / 10.4 / 15.6 / 20.8 / 31.25 / 41.7 / 62.5 / 125 / 250 / 500
- **Spreading Factor** - 5-12
- **Coding Rate** - 5-8
- **TX Power (dBm)** - 0-30
Changes are written to the device (via the `set` command internally) and are persisted on the device.
### Messages Tab — DM Retry Settings
Configure how direct messages are retried when delivery is not confirmed. Settings are organized into two groups based on whether the contact has a known route:
**When path is known:**
- **Direct retries** - How many times to resend via the current route before trying alternatives (default: 3)
- **Flood retries** - How many flood attempts after direct retries when no extra paths are configured (default: 1)
- **Interval (s)** - Minimum seconds between direct retry attempts (default: 30)
**When no path:**
- **Max retries** - How many flood retry attempts (default: 3)
- **Interval (s)** - Minimum seconds between flood retry attempts (default: 60)
**Other:**
- **Grace period (s)** - After all retries fail, keep listening for a late ACK this long before giving up (default: 60)
The app automatically picks one of four retry strategies depending on the contact's route status and configured paths. For full details, see [DM Delivery & Retry Logic](dm-retry-logic.md).
### Group Chat Tab
**Quote Settings:**
- **Max quote length** - Maximum number of bytes to include when quoting a message
### Message Retention
**Message Retention:**
- **Live view days** - Number of days of messages shown in the live view (older messages are archived)
**Route popup** (applies to both channel messages and DMs):
- **Auto-close after (s)** - Seconds before the route popup (shown when tapping "SNR | Hops" under a message) closes automatically (default: 8)
- **Don't close automatically** - Popup stays open until you tap outside it
### Interface Tab
Controls small notification toasts shown after actions (e.g. "Advert Sent", errors).
- **Auto-close after (s)** - Seconds before a notification closes (default: 2.0)
- **Don't close automatically** - Toasts stay visible until dismissed via their close button
- **Position on screen** - Top-left / Top-right / Bottom-left / Bottom-right / Center
### Appearance Tab
**Theme:**
- **Dark / Light** - Toggle between dark and light UI themes. The preference is saved in local browser storage
**Quick Access Buttons:**
- **Button size (px)** - Adjust the size of FAB buttons (default: 56)
- **Spacing (px)** - Space between FAB buttons (default: 12)
- **Position** - Reset FAB position to default (top-right)
### Regions Tab
Manage MeshCore region scopes (also called flood scopes). See [Region Scopes](#region-scopes) above for the full walkthrough; in short:
- Add new regions by name (the 16-byte scope key is derived automatically from `SHA256('#'+name)`)
- Pick the **default region** with the radio button — pushed to the firmware (requires v1.15+) and used by any channel without an explicit override
- Pick **None** to clear the firmware default
- Delete regions you no longer need (channels using a deleted region revert to "no scope")
---
## System Log
@@ -581,7 +804,10 @@ To get the full PWA experience with app badge counters:
## Getting Help
- **Full README:** [README.md](../README.md)
- **Contact Management Guide:** [contact-management.md](contact-management.md)
- **Repeater Management:** [rpt-mgmt.md](rpt-mgmt.md)
- **DM Delivery & Retry Logic:** [dm-retry-logic.md](dm-retry-logic.md)
- **Bluetooth Pairing Guide:** [meshcore_bluetooth_pairing.md](meshcore_bluetooth_pairing.md)
- **Troubleshooting:** [troubleshooting.md](troubleshooting.md)
- **Architecture:** [architecture.md](architecture.md)
- **Container Watchdog:** [watchdog.md](watchdog.md)
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# Docker entrypoint for mc-webui
#
# Disconnects stale BLE connections before starting the app.
# BlueZ on the host auto-reconnects trusted devices, leaving stale GATT
# notification handles that block bleak from establishing a new session.
# A clean disconnect here ensures the app starts with a fresh BLE state.
set -e
# If MC_BLE_ADDRESS is set, clean up stale BLE connections
if [ -n "$MC_BLE_ADDRESS" ]; then
DBUS_PATH="/org/bluez/hci0/dev_${MC_BLE_ADDRESS//:/_}"
# Check if device is connected via BlueZ
CONNECTED=$(dbus-send --system --print-reply --dest=org.bluez \
"$DBUS_PATH" org.freedesktop.DBus.Properties.Get \
string:org.bluez.Device1 string:Connected 2>/dev/null \
| grep -c "boolean true" || true)
if [ "$CONNECTED" = "1" ]; then
echo "[entrypoint] BLE device $MC_BLE_ADDRESS is connected, disconnecting stale session..."
dbus-send --system --print-reply --dest=org.bluez \
"$DBUS_PATH" org.bluez.Device1.Disconnect 2>/dev/null || true
sleep 2
echo "[entrypoint] Stale BLE connection cleared"
fi
fi
# Run the main application
exec "$@"
+21 -15
View File
@@ -136,22 +136,26 @@ def auto_detect_usb_device() -> str:
log(f"Error during USB device auto-detection: {e}", "ERROR")
return None
def is_tcp_connection() -> bool:
"""Check if the application is configured to use a TCP connection instead of a serial port."""
def _read_env_value(key: str) -> str:
"""Read a value from the .env file. Returns empty string if not found."""
env_file = os.path.join(MCWEBUI_DIR, '.env')
if os.path.exists(env_file):
try:
with open(env_file, 'r') as f:
for line in f:
if line.startswith('MC_TCP_HOST='):
val = line.split('=', 1)[1].strip().strip('"\'')
if val:
return True
if line.startswith(f'{key}='):
return line.split('=', 1)[1].strip().strip('"\'')
except Exception as e:
log(f"Failed to read .env file for TCP host: {e}", "WARN")
log(f"Failed to read .env file for {key}: {e}", "WARN")
return ''
return False
def is_tcp_connection() -> bool:
"""Check if the application is configured to use a TCP connection instead of a serial port."""
return bool(_read_env_value('MC_TCP_HOST'))
def is_ble_connection() -> bool:
"""Check if the application is configured to use a BLE connection."""
return bool(_read_env_value('MC_BLE_ADDRESS'))
def reset_esp32_device():
"""Perform a hardware reset on ESP32/LoRa device using DTR/RTS lines via ioctl."""
@@ -433,7 +437,8 @@ def handle_unhealthy_container(container_name: str, status: dict):
restart_success = False
if container_name == 'mc-webui':
recent_restarts = count_recent_restarts(container_name, minutes=8)
if recent_restarts >= 3 and not is_tcp_connection():
uses_serial = not is_tcp_connection() and not is_ble_connection()
if recent_restarts >= 3 and uses_serial:
log(f"{container_name} has been restarted {recent_restarts} times in the last 8 minutes. Attempting hardware USB reset.", "WARN")
# Stop the container first so it releases the serial port
run_compose_command(['stop', container_name])
@@ -442,8 +447,8 @@ def handle_unhealthy_container(container_name: str, status: dict):
time.sleep(5) # Give OS time to re-enumerate the device
restart_success = start_container(container_name)
else:
if recent_restarts >= 3 and is_tcp_connection():
log(f"{container_name} has been restarted {recent_restarts} times in the last 8 minutes. TCP connection used, skipping hardware USB reset.", "WARN")
if recent_restarts >= 3 and not uses_serial:
log(f"{container_name} has been restarted {recent_restarts} times in the last 8 minutes. Non-serial connection, skipping hardware USB reset.", "WARN")
restart_success = restart_container(container_name)
else:
# Restart the container
@@ -512,7 +517,8 @@ def handle_unresponsive_device(container_name: str, status: dict):
restart_success = False
if container_name == 'mc-webui':
recent_restarts = count_recent_restarts(container_name, minutes=8)
if recent_restarts >= 3 and not is_tcp_connection():
uses_serial = not is_tcp_connection() and not is_ble_connection()
if recent_restarts >= 3 and uses_serial:
log(f"{container_name} has been restarted {recent_restarts} times in the last 8 minutes. Attempting hardware USB reset.", "WARN")
# Stop the container first so it releases the serial port
run_compose_command(['stop', container_name])
@@ -521,8 +527,8 @@ def handle_unresponsive_device(container_name: str, status: dict):
time.sleep(5) # Give OS time to re-enumerate the device
restart_success = start_container(container_name)
else:
if recent_restarts >= 3 and is_tcp_connection():
log(f"{container_name} has been restarted {recent_restarts} times in the last 8 minutes. TCP connection used, skipping hardware USB reset.", "WARN")
if recent_restarts >= 3 and not uses_serial:
log(f"{container_name} has been restarted {recent_restarts} times in the last 8 minutes. Non-serial connection, skipping hardware USB reset.", "WARN")
restart_success = restart_container(container_name)
else:
# Restart the container
+1 -1
View File
@@ -392,7 +392,7 @@ class TestBackup:
db.create_backup(backup_dir)
backups = db.list_backups(backup_dir)
assert len(backups) == 1
assert 'mc-webui.' in backups[0]['filename']
assert backups[0]['filename'].endswith('.db')
def test_list_backups_empty_dir(self, db):
with tempfile.TemporaryDirectory() as tmp:
+246
View File
@@ -0,0 +1,246 @@
"""
Unit + integration tests for the per-channel region-scope data layer.
Run: python -m pytest tests/test_regions.py -v
"""
import sqlite3
import tempfile
from pathlib import Path
import pytest
from app.database import Database
from app.meshcore.regions import (
MAX_NAME_LEN,
derive_scope_key,
derive_scope_key_hex,
is_valid_region_name,
)
@pytest.fixture
def db():
with tempfile.TemporaryDirectory() as tmp:
yield Database(Path(tmp) / 'test.db')
# ================================================================
# Key derivation (known vectors)
# ================================================================
class TestKeyDerivation:
# Firmware rule: key = SHA256('#' + name)[:16]
# Vectors computed offline and baked in to catch regressions.
def test_pl(self):
assert derive_scope_key_hex('pl') == '89e07394d9523e8996cae464c7770516'
def test_pl_ma(self):
assert derive_scope_key_hex('pl-ma') == '71a012b2fcfee9b6a29a28729236f1b8'
def test_krakow(self):
assert derive_scope_key_hex('krakow') == '1482a54016edec3b8d13a879b7af62a3'
def test_returns_16_bytes(self):
assert len(derive_scope_key('pl')) == 16
def test_hash_input_skips_existing_hash_prefix(self):
# '#pl' must produce the same key as 'pl' — firmware does not double-prefix.
assert derive_scope_key_hex('#pl') == derive_scope_key_hex('pl')
# ================================================================
# Name validation (firmware RegionMap::is_name_char rule)
# ================================================================
class TestNameValidation:
@pytest.mark.parametrize('name', [
'pl', 'pl-ma', 'pl#test', '$EU', '999', 'Malopolska', 'a',
'-leading-dash-ok', 'UPPER', 'mixedCase',
# Firmware rule `c >= 'A'` (0x41) admits underscore (0x5F) too.
'my_region',
])
def test_valid(self, name):
ok, err = is_valid_region_name(name)
assert ok, f'expected valid, got error: {err}'
@pytest.mark.parametrize('name', [
'', # empty
' pl', # space (0x20)
'my region', # embedded space
'a.b', # dot (0x2E)
'a,b', # comma (0x2C)
'a/b', # slash (0x2F)
'a:b', # colon (0x3A)
'a+b', # plus (0x2B)
'a@b', # at-sign (0x40)
'a(b', # (0x28)
'a*b', # (0x2A)
])
def test_invalid(self, name):
ok, _ = is_valid_region_name(name)
assert not ok, f'expected invalid for: {name!r}'
def test_too_long_rejected(self):
too_long = 'a' * (MAX_NAME_LEN + 1)
ok, _ = is_valid_region_name(too_long)
assert not ok
def test_at_length_limit_accepted(self):
at_limit = 'a' * MAX_NAME_LEN
ok, _ = is_valid_region_name(at_limit)
assert ok
def test_non_string_rejected(self):
for bad in [None, 42, b'pl', ['pl']]:
ok, _ = is_valid_region_name(bad)
assert not ok
def test_accented_chars_accepted(self):
# Firmware rule admits any byte >= 'A' (0x41), which includes all UTF-8
# continuation bytes (>=0x80), so accented chars pass.
ok, _ = is_valid_region_name('Malopolska')
assert ok
ok, _ = is_valid_region_name('Kraków')
assert ok
# ================================================================
# DB: region CRUD
# ================================================================
class TestRegionCrud:
def test_create_and_list(self, db):
rid = db.create_region('pl', derive_scope_key_hex('pl'))
assert rid > 0
regions = db.list_regions()
assert len(regions) == 1
assert regions[0]['name'] == 'pl'
assert regions[0]['key_hex'] == derive_scope_key_hex('pl')
assert regions[0]['is_default'] == 0
def test_duplicate_name_raises(self, db):
db.create_region('pl', derive_scope_key_hex('pl'))
with pytest.raises(sqlite3.IntegrityError):
db.create_region('pl', derive_scope_key_hex('pl'))
def test_get_by_id_and_name(self, db):
rid = db.create_region('pl-ma', derive_scope_key_hex('pl-ma'))
by_id = db.get_region(rid)
by_name = db.get_region_by_name('pl-ma')
assert by_id and by_name
assert by_id['id'] == by_name['id'] == rid
def test_get_missing_returns_none(self, db):
assert db.get_region(999) is None
assert db.get_region_by_name('missing') is None
def test_delete(self, db):
rid = db.create_region('pl', derive_scope_key_hex('pl'))
assert db.delete_region(rid) is True
assert db.get_region(rid) is None
assert db.delete_region(rid) is False # already gone
def test_list_ordered_by_name(self, db):
db.create_region('pl-ma', derive_scope_key_hex('pl-ma'))
db.create_region('pl', derive_scope_key_hex('pl'))
db.create_region('krakow', derive_scope_key_hex('krakow'))
names = [r['name'] for r in db.list_regions()]
assert names == ['krakow', 'pl', 'pl-ma']
# ================================================================
# DB: default region
# ================================================================
class TestDefaultRegion:
def test_no_default_initially(self, db):
assert db.get_default_region() is None
def test_set_and_get_default(self, db):
rid = db.create_region('pl', derive_scope_key_hex('pl'))
db.set_default_region(rid)
d = db.get_default_region()
assert d is not None
assert d['id'] == rid
assert d['is_default'] == 1
def test_set_default_clears_previous(self, db):
a = db.create_region('pl', derive_scope_key_hex('pl'))
b = db.create_region('pl-ma', derive_scope_key_hex('pl-ma'))
db.set_default_region(a)
db.set_default_region(b)
# only one default
defaults = [r for r in db.list_regions() if r['is_default']]
assert len(defaults) == 1
assert defaults[0]['id'] == b
def test_set_default_none_clears_all(self, db):
rid = db.create_region('pl', derive_scope_key_hex('pl'))
db.set_default_region(rid)
db.set_default_region(None)
assert db.get_default_region() is None
# ================================================================
# DB: channel_scopes mapping
# ================================================================
class TestChannelScopes:
def test_set_and_get(self, db):
rid = db.create_region('pl', derive_scope_key_hex('pl'))
db.set_channel_scope(3, rid)
scope = db.get_channel_scope(3)
assert scope is not None
assert scope['region_id'] == rid
assert scope['name'] == 'pl'
assert scope['key_hex'] == derive_scope_key_hex('pl')
def test_get_missing_returns_none(self, db):
assert db.get_channel_scope(5) is None
def test_set_none_clears(self, db):
rid = db.create_region('pl', derive_scope_key_hex('pl'))
db.set_channel_scope(3, rid)
db.set_channel_scope(3, None)
assert db.get_channel_scope(3) is None
def test_upsert_replaces(self, db):
a = db.create_region('pl', derive_scope_key_hex('pl'))
b = db.create_region('pl-ma', derive_scope_key_hex('pl-ma'))
db.set_channel_scope(3, a)
db.set_channel_scope(3, b)
scope = db.get_channel_scope(3)
assert scope['region_id'] == b
def test_cascade_on_region_delete(self, db):
rid = db.create_region('pl', derive_scope_key_hex('pl'))
db.set_channel_scope(3, rid)
db.set_channel_scope(4, rid)
db.delete_region(rid)
assert db.get_channel_scope(3) is None
assert db.get_channel_scope(4) is None
def test_get_all_channel_scopes(self, db):
a = db.create_region('pl', derive_scope_key_hex('pl'))
b = db.create_region('pl-ma', derive_scope_key_hex('pl-ma'))
db.set_channel_scope(0, a)
db.set_channel_scope(3, b)
all_scopes = db.get_all_channel_scopes()
assert set(all_scopes.keys()) == {0, 3}
assert all_scopes[0]['name'] == 'pl'
assert all_scopes[3]['name'] == 'pl-ma'
# ================================================================
# Schema presence
# ================================================================
class TestSchema:
def test_regions_and_channel_scopes_tables_exist(self, db):
with db._connect() as conn:
tables = {r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()}
assert 'regions' in tables
assert 'channel_scopes' in tables