Three tabs open made every endpoint answer in ~20s, which two sessions read as
contention on a shared server-side lock. It wasn't the server. With 3 tabs open,
/health - which touches neither the database nor the device - measured a 14.7s
median from inside a tab and 11ms from a client outside the browser at the same
instant. The server was idle throughout.
Socket.IO clients pinned transports: ['polling'], upgrade: false, so each tab
held one HTTP connection open for its whole lifetime. Browsers allow six
concurrent HTTP/1.1 connections per origin, shared across every tab, so three
tabs consumed the pool and everything else queued in the browser waiting for a
free connection. /proc/net/tcp in the container confirmed it: pinned at exactly
6 established connections, unmoving.
The pin dates from 1d47c9c, when werkzeug had no WebSocket support and every
upgrade attempt returned HTTP 500. python-engineio==4.8.1 (pinned five weeks
later, in d3590f9) pulls in simple-websocket, which fixed that; the workaround
had outlived its premise. Drop it and use the default transports, which open on
polling and upgrade. A WebSocket is not part of the HTTP pool, so the pool is
released. Where the upgrade is blocked - a proxy that drops the Upgrade header -
the client stays on polling by itself, which is exactly today's behaviour.
Measured with 3 tabs, in-page medians: /health 14664ms -> 12ms, /api/status
19282ms -> 64ms, both now matching what the same probes read from outside the
browser. All three tabs report transport "websocket", server pushes still
arrive over it, and the log no longer fills with "Session is disconnected"
(0 occurrences, 0 tracebacks across the run).
Also corrects the earlier diagnosis in the docs: the per-endpoint timings that
looked like a lock were measured request->requestfinished in the browser, which
includes connection-queue time, so every endpoint flattened to the same figure
regardless of its own cost.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/api/status is polled by every open page (on load, every 60s, and on
visibility resume) and cost ~266-450ms in-container while doing no device
I/O at all — check_connection() is just an attribute read, and /health
returns in ~1ms, so all of it was SQLite.
Two calls were over-fetching:
- get_stats() ran COUNT(*) over 11 tables to answer a question about 2.
COUNT(*) FROM echoes alone was ~200ms over 19k rows and the result was
discarded. That table only grows, so the endpoint kept getting slower.
- get_channel_messages(limit=1) was a SELECT * fetching every column,
raw_packet included, to read one timestamp. ORDER BY timestamp has no
usable index (idx_cm_channel_ts leads with channel_idx), so the plan
was a full SCAN through two temp B-trees. MAX(timestamp) is served off
that index as a covering scan instead.
Replaced with Database.get_status_summary(): three scalar subqueries on
one connection. ~371ms -> ~46ms for the DB work; endpoint median ~57ms
in-container, ~78ms from the page. Response verified byte-identical.
This does not address the multi-tab congestion — at one poll per tab per
60s /api/status was never that cause — but it removes a recurring cost
and re-baselines the probe those measurements are taken with.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every incoming echo triggers a sweep of the whole rendered message list, and
refreshMessagesMeta() awaited one /api/messages/<id>/meta per message inside
the loop. Messages that never gain a route (nothing heard them) never stop
qualifying for the sweep, so the same ~180 messages were re-fetched every few
seconds: 7,500 requests in nine minutes on a single tab, each opening its own
SQLite connections. The single-threaded werkzeug server — the same one
production runs — had no room left for anything else, so the UI hung on
"Loading messages..." / "Connecting..." while the device was in fact connected.
Add GET /api/messages/meta?ids=... resolving the whole sweep with a handful of
queries, batching the row and echo lookups, and have the client collect ids
first and fetch them in chunks. The per-message endpoint stays for forced
single refreshes; both now share _build_message_meta() and the existing
_build_channel_secrets / _get_row_pkt_payload helpers, so the payload is
unchanged (verified byte-identical against the old response).
Measured on the local container, 500 messages rendered / 181 needing meta:
one 273 ms request in place of 181 sequential ones at ~82 ms each.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chat view only ever grew by socket push, so anything that arrived
while the connection was down was never drawn. Android tears the
connection down behind a locked screen, and the wrapper keeps the same
page alive for days, so the list stopped at the last message that got
through until the app was force-stopped. A browser tab hid the same bug
by reloading the page on resume.
Every way back from a gap now re-reads the list from the server: the
socket reconnecting, the page becoming visible after more than a glance
away, a heartbeat noticing its own tick arrived far too late (the page
was frozen), a new Refresh item in the menu, and window.__mcAppResumed,
which the wrapper calls from onResume since a WebView is not guaranteed
to report the page as hidden at all. Direct messages get the same
treatment.
Verified in Chrome against the local container: all five triggers fire a
resync, with no page errors and the list intact afterwards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Quote button produced `@[user] »text« ` — a shape mc-webui invented back
when it could not send newlines, and one that other MeshCore clients neither
write nor understand. It now writes the quote on its own line behind a '>'
and leaves the cursor underneath it, matching the plain-text convention some
users already type by hand.
processQuotes() styles both syntaxes, so messages sent by older builds keep
their formatting. The '>' match is anchored to a line start or a leading
@[mention] badge and looks for the escaped '>', so neither "5 > 3" in
prose nor the brackets of generated tags can trigger it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The app already knew exactly which build was running - a calendar version
of commit date plus short hash - but nothing gave a release a name users
could quote, and the repo had no tags at all.
Adds a VERSION file as the single source of truth for a SemVer release
number, read by app/version.py alongside the existing build string rather
than replacing it: the number is for people, the build is for pinning down
a deploy, and both ship in /api/version and the template context. The menu
shows the release first with the build underneath. #versionText still holds
the build string, because the remote-update poller compares it to detect
that the server came back on a new build.
Resolution order is unchanged (frozen file > git > fallback), and a frozen
file written before this change still yields a correct release number, so
an already-deployed server does not need re-freezing to stay sane. The
container has no git, hence COPY VERSION into the image - otherwise a plain
'docker compose build' reports 0.0.0.
scripts/release.sh cuts a release from main: it refuses a dirty tree, a
wrong branch, a malformed number or an existing tag, extracts the notes
from the matching whatsnew section, then tags, pushes and publishes via gh.
Numbering starts at 2.1.0 rather than 1.x: the v2 line has been in
production since March and 'v1' is the archived pre-migration branch, so
1.x would have been ambiguous. Sections in whatsnew before 2.1.0 keep
their date-only headings - they were never tagged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Time range and the filter bar reset on every open, so a working set like
"Last 1 day + 2/3-byte" had to be re-entered each time. The controls are
now stored in localStorage - a personal working set, not device state,
so it stays out of the database.
Only user-driven changes are stored: programmatic ones, such as the
deep-link widening the range to 7 days to find an older message, must not
overwrite the saved set. Restoring is skipped entirely when arriving via
a deep link, where a saved filter could hide the very message the user
clicked through to.
The Routes segment length rides along - same "set it again every visit"
annoyance. A stored value for an option that no longer exists is ignored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The map used to plot every located repeater on top of the selected route,
which buried the path the user actually asked for. Both extras are now
opt-in, via a Leaflet control in the map corner - the shared filter bar
would be the wrong place, since these apply to the Map view only.
- "All repeaters": the purple contact markers, off by default (also saves
plotting ~650 markers on every render)
- "Alternative paths": the selected message's other echoes, each in its
own light hue, keyed to a swatch on the matching sidebar row
Echoes of one message usually share a long prefix, so a segment is drawn
once only - the primary route claims it, and an alternative then shows
exactly where it diverges instead of hiding underneath. Each alternative
also gets a dot at its last resolved hop, since the difference is often
just the final one.
Toggling re-renders without refitting the bounds, so it never throws away
the viewport the user panned or zoomed to.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clicking a route in the chat path popup now opens Path Analyzer on the
Map view with that message selected and that exact echo path drawn,
instead of copying the route to the clipboard. Copy stays available as
a small per-route clipboard icon in the popup.
Deep link flow: popup click stores {packet_hash, path hex} in
window.paDeepLink; the modal show handler builds the iframe URL with
?hash=&path=; the analyzer resolves the message after load (widening
the time range once to 7 days if needed), matches the echo by raw path
hex (fallback: shortest), and switches to the map. A plain menu open
still loads the analyzer without any deep link.
Verified live via Playwright: clicked the 3rd (non-shortest) route of a
multi-route message - the map opened with exactly that echo selected,
including the 3-to-7-day widening retry (message was 4 days old).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The class defined _refresh_channel_secret twice. The second definition
(legacy, returns None) shadowed the first one added in 77c3ffa, so
send_channel_message never got the channel secret, expected_payloads was
always empty, and every sent-message echo correlation fell through to the
loose 60s channel-hash-byte fallback. Any foreign GRP_TXT echo on the same
channel inside that window could then be mis-assigned to our sent message
(wrong hash + physically impossible path on the badge and in Path Analyzer).
Drop the legacy definition and let set_channel use the surviving one, which
also re-reads the secret and updates cache + DB.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New 4th view counting consecutive hop segments (n-grams) across all
routed echo paths, regardless of position in the path:
- segment length selector (2/3/4 hops), table sorted by echo count by
default; columns: Echoes, distinct Messages, As path end (how often
the segment is the final part of the path reaching us)
- resolved contact names shown under the hash chips (ambiguous/unknown
marked like elsewhere)
- row click fills the repeater filter with the segment and jumps to the
message list
- the repeater filter now accepts consecutive sequences chained with
> or an arrow (e.g. AFE6>6E9A or hash>name); single values behave
exactly as before, spaces stay usable inside contact names
- view switcher buttons show icons only on xs screens so 4 buttons fit
Idea by Daniel - group paths by recurring hop sequences to see which
routes carry the most traffic.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Show the channel name (dimmed, next to the sender) on map-view tiles
- Clicking a message now auto-selects and draws its shortest routed echo
(fewest hops, ties -> first); re-clicking keeps the user's echo choice
- Mobile: map takes a fixed 45% of the viewport height and the path list
gets the remaining space (was min 300px map / max 40% list)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On viewports below 768px the toolbar collapses to a single line (view
switcher + Filters toggle + counter); filters expand on demand and an
active-filter count badge shows on the toggle when collapsed. Desktop
layout is unchanged (the panel renders with display: contents). Frees
most of the vertical space for the path list on the mobile Map view.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Login retry prompt now prefills the saved password (a wrong stored
password and an unreachable repeater are indistinguishable, so a
connection-caused failure no longer forces retyping). Adds
GET /api/repeaters/<pk>/password for the trusted local UI.
- Repeater list: "last login" moves to its own line so a long path keeps
the full row width on narrow phones.
- Settings -> Location: "Pick from map" button opens a Leaflet picker;
clicking the map fills lat/lon and marks the section dirty.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The path-hash-size filter accepts a comma list of sizes; a new
'2/3-byte' option matches messages with any routed echo using a 2- or
3-byte hash.
Verified live: 2/3-byte returns exactly the union of the 2-byte and
3-byte sets (220 = 208 + 12 with zero overlap misses on live data).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Drawn paths now use red (#dc3545) for hop markers and polylines,
clearly distinct from the purple base repeater dots (origin stays
green, ambiguous candidates amber)
- Resolved hops render as numbered badges matching the legend order,
with a permanent name label next to each point (origin included)
- Manual candidate assignments are reversible: a picked hop shows an
undo icon in the legend, and a 'Reset picks' button clears every
manual assignment on the current path
Verified live via Playwright: line/marker colors, badge numbers and
name labels, pick -> per-hop undo -> re-pick -> reset-all flow, no
page errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace per-message get_echoes_for_message() calls (N+1, up to 500
queries per request) with a single get_echoes_for_payloads() batch
query. With connection-per-call over a Windows bind mount each query
cost ~66 ms, making the endpoint take 33 s and time out in the UI;
now it completes in ~0.5 s.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rather than a card-per-row transform (too much scrolling for 400+
messages), narrow viewports (<=576px) keep the table but shed
secondary width:
- Hash, HB and Echoes columns hidden; packet hash + HB surface as a
line at the top of the expanded detail row instead (still copyable)
- time renders as short date/time stacked on two lines (full timestamp
on wide screens)
- sender/channel/message-preview columns capped with ellipsis, tighter
cell padding, headers allowed to wrap
- stats view: Messages and As-last-hop columns hidden on phones
(Repeater, Contact, Relayed, Avg SNR remain), contact names truncated
- expanded echo paths now wrap fully within the viewport, keeping every
hop chip and the jump-to-map button reachable
Root cause of the first attempt failing: the media block sat mid-
stylesheet, so later equal-specificity base rules overrode it - it now
sits last, with a comment pinning it there.
Verified live via Playwright at 390px: zero horizontal overflow in
Messages (incl. an expanded 20-hop path, map button on-screen) and
Repeaters; desktop 1280px layout unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The repeater filter input now accepts either form: pure-hex input still
prefix-matches hop hash tokens, and any input also matches the names of
contacts each token resolves to (pubkey-prefix candidates, memoized per
token). With 1-byte hashes a name can match via a colliding candidate,
so the filter is intentionally inclusive: it keeps messages where the
named repeater could be on the path.
Verified live via Playwright: hex prefix regression OK, name search
returns only messages with a token resolving to the named contact,
memoized pass over 606 messages takes <1 ms.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Free-text case-insensitive substring filter on message content, next to
the other toolbar filters; combines with them and is reset by Clear.
Verified live via Playwright: content match returns only matching rows
(5/606), combines with the sender filter, Clear restores the full set.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Each routed echo line in the Messages table gets a map icon that
switches to the Map view with that message + echo pre-selected and
its path drawn; the sidebar scrolls the selection into view
- Map sidebar entries show the message content under the sender: one
truncated line normally, full text when the message is selected;
sidebar entry markup switched from innerHTML to DOM building so
network-supplied sender/content strings are never parsed as HTML
- Fix: path drawing (fitBounds) now runs after invalidateSize in the
same deferred step - drawing immediately after unhiding the map
container computed bounds against a stale size and left the map at
world zoom when jumping from the table
Verified live via Playwright: jump selects the right echo (route
chips match the sidebar entry), path draws at zoom 10, previews
truncate on unselected entries and expand on the selected one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- HB column in the message table: distinct hash sizes (bytes per hop)
across the message's routed echoes, e.g. '1' or '1,2'; em dash when
no routed echo
- HB filter (Any/1-byte/2-byte/3-byte): matches when any routed echo
uses that hash size; 0-hop echoes are excluded since their stored
hash_size is meaningless; combines with the other filters and is
reset by Clear
Verified live via Playwright: 1B/2B/3B filters each return only
messages with a matching routed echo (183/166/9 of 606, zero false
positives), combined hops+HB filtering works, Clear resets.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Third view toggle: Map. Leaflet lazy-init on first activation with
invalidateSize() once the container is visible; OSM tiles as in the
main app map
- Base layer: repeater contacts with geo plotted as purple circle
markers (name + pubkey prefix popup), from /api/contacts/cached
- Sidebar lists filtered messages that have routed echoes; click a
message to see its echoes, click an echo to draw it
- Hop resolution by pubkey prefix: single geo candidate draws a solid
hop point; collisions render all candidates as amber markers plus a
legend row with clickable pick-chips (pick resolves the hop and
redraws); unknown hops are listed as 'unknown / no position'
- Polyline follows path order with the sender (name-matched contact) as
origin when available; segments bridging unresolved hops are dashed;
map fits bounds to the drawn path
- Clear button erases the drawn path; stale selections are dropped when
filters change underneath
Verified live via Playwright: 252 repeater markers, path + legend
render, ambiguous-hop candidate pick adds a polyline segment, clear
empties the layer, view toggling stays stable with no page errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Messages / Repeaters view toggle in the toolbar; stats aggregate over
the currently filtered messages so both views share one filter state
- Per exact hash token: Relayed (echoes containing the token anywhere),
distinct Messages, As-last-hop count, and Avg SNR counted only over
echoes where the token is the final hop (SNR is measured at our
receiver, so it is never attributed to intermediate hops)
- Sortable numeric columns (default Relayed desc, nulls always last)
- Token -> contact enrichment via /api/contacts/cached?format=full
pubkey prefix match: single candidate shows the name, collisions show
'ambiguous (n)' with candidate names in the tooltip (matcher shared
with the stage-5 map)
- Clicking a stats row applies that token as the message filter and
switches back to the Messages view
Verified live via Playwright: aggregates match an in-page manual
recount exactly, sort orders correct, row-click cross-navigation works,
1-byte and 2-byte tokens kept distinct.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Message rows expand to show each echo as per-hop repeater hash chips
(A3 -> 3B -> 5E) with direction badge, SNR and received time; tokens
split per that echo's own hash_size (same logic as showPathsPopup),
0-hop echoes shown as 'Direct (flood, 0 hops)'
- Chip click copies the repeater hash, echo line click copies the comma
route (parity with the chat path popup)
- Filter bar (client-side, combinable, 150ms debounce): hop count
(any/0/1/2/3/4+, matches if any echo has that hop count), repeater
hash token (hex-normalized prefix match, bridges mixed hash sizes),
sender substring; Clear button appears when any filter is active
- 'N of M messages' counter and a dedicated empty state when filters
match nothing
Verified live via Playwright: expand/collapse, both copy actions,
hops=2 yields only rows with a 2-hop echo (39/602), combined
token+hops filter, sender filter, clear resets to full set.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Full-screen iframe panel wired per the add-fullscreen-panel checklist:
- /path-analyzer Flask view + standalone template (theme bootstrap before
CSS, local vendor assets, Leaflet included for the stage-5 map view)
- path-analyzer.js: days selector (1/3/5/7), message table (time, channel,
sender, preview, copyable packet hash, hops, echo count), spinner/empty
states, local toast helpers fed from /api/ui/settings
- #pathAnalyzerModal in index.html, iframe src set on show.bs.modal for
fresh data each open; FAB button + purple gradient class
- menu entry in #mainMenu (Tools) + placement radio row in Settings ->
Appearance; registered in ITEM_PLACEMENT_DEFS/DEFAULTS (default: menu)
- style.css: #pathAnalyzerModal added to all three narrow-viewport
fullscreen-override selector lists
Verified live via Playwright at 1280px and 390px, light + dark: modal
fills the viewport exactly, placement radios move the entry both ways,
iframe reloads on every open.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- New GET /api/path-analyzer/messages?days=N endpoint: returns channel
messages from ALL channels for the last N days (clamped 1..30) with
echoes attached per message (path, snr, hash_size, direction,
received_at), channel names and packet hashes included.
- New db.get_echoes_for_payloads(): chunked IN-clause batch fetch
(<=500 params per query) instead of one query per message.
- Extracted _build_channel_secrets() and _get_row_pkt_payload() helpers
from get_messages(); /api/messages behavior unchanged (verified live:
field set identical, packet hashes and echo paths match the new
endpoint for the same messages).
- Legacy rows with uncomputable pkt_payload degrade to packet_hash null
+ empty echoes instead of being dropped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- POST /api/repeaters/<pk>/action {action}: whitelisted one-shot commands
via repeater_cmd_wait — zerohop_advert (advert.zerohop), flood_advert
(advert), clock_sync (clock sync), reboot; admin-gated
- Replies surfaced verbatim with an ok flag (reply starts with OK), so
firmware refusals like "ERR: clock cannot go backwards" show as-is
- reboot special case: the firmware restarts without ever replying, so a
clean send followed by silence reports success ("Reboot command sent")
- Actions pane: action rows with inline result lines, flood advert
styled as warning ("Not recommended - high network load"), Danger zone
card with confirm()-guarded Reboot and a muted note that erase is only
available on the USB serial console (firmware restriction)
Verified live against PL-KRA Wegrzce 2: zero-hop advert replied
"OK - zerohop advert sent" and the advert arrived back at our device
(log: Advert from 'PL-KRA Wegrzce 2' type=2); clock sync surfaced the
firmware refusal; reboot confirm-cancel path checked in Playwright
(reboot itself intentionally not executed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- GET/POST /api/repeaters/<pk>/settings?section=X: sequential text-CLI
get/set batches via repeater_cmd_wait; per-field values/errors on read,
per-field ok|failed|reboot_required classification on write (reply
starting ok/password now = ok, mention of reboot = reboot_required);
admin-gated via new _require_repeater_admin helper (also reused by /cli)
- Settings pane: accordion with 8 sections (Basic, Radio, Location,
Features, Network health, Advertisement, Operator info, Advanced),
lazy-loaded on first expand (each field is one mesh round-trip),
per-section Refresh/Apply, dirty tracking with header badges
- Field types: on/off switches, 0/1 switches, select (loop.detect),
composite radio (freq,bw,sf,cr) with reboot warning + confirm dialog,
write-only admin password (updates the saved password on success),
owner.info textarea with pipe-to-newline mapping
- Failed writes keep the field dirty and surface the firmware reply
(e.g. "Error: interval range is 60-240 minutes") under the input
- Strip "%" suffix when loading number fields (get dutycycle -> "70.0%")
Verified live against PL-KRA Wegrzce 2: all 8 sections read cleanly,
flood.max 64->63->64 write round-trip, firmware range rejection surfaced
per-field, radio confirm-cancel path, dutycycle % parsing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
height: max(260px, calc(100vh - 430px)) instead of a fixed 340px, so
the scrollback grows with the window while staying usable on small
screens.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Remote text console for the managed repeater. Core mechanism:
repeater_cmd_wait() sends the command and synchronously waits for the
reply — CLI replies arrive as CONTACT_MSG_RECV txt_type=1 with no
protocol-level correlation, so correlation = repeater lock (single
command in flight) + sender-prefix match. A single-slot waiter is
checked at the top of _on_dm_received: matched CLI replies are
consumed there and never stored as chat DMs; unmatched ones (e.g.
console fire-and-forget cmd) keep the legacy DM behavior. Wait time
derives from the device-suggested timeout (10-45 s clamp).
POST /api/repeaters/<pk>/cli is admin-gated (403 for guest sessions:
firmware silently drops guest text commands, which would look like a
timeout). Pane: dark terminal styled after the Console module, quick-
command chips, Enter to send, per-repeater arrow-key history in
localStorage, elapsed-time line, inline timeout errors (lost replies
happen over radio — a manual retry typically succeeds).
Settings (stage 7) and Actions (stage 8) will reuse repeater_cmd_wait.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
List view: all zero-hop neighbours (fetch_all_neighbours paginates the
firmware's ~14-entry pages), rows show resolved contact name (or
[pubkey prefix] for unknown repeaters), heard-ago and SNR; count in
the toolbar. Names/positions are enriched server-side by prefix-
matching device contacts with the DB contact cache as fallback.
Map view (List/Map toggle, shown only when something is mappable):
Leaflet with the managed repeater as a red marker, positioned
neighbours in green, dashed connection lines labeled with permanent
SNR tooltips, and a footnote counting neighbours without a known
position. Toggle hidden entirely when no coordinates exist.
Verified live on PL-KRA Wegrzce 2: 37/37 neighbours fetched, 20
mappable, SNR labels rendered.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All Cayenne LPP channels shown at once (no channel dropdown like the
standard app): one card per channel with typed rows — icon, name,
value and unit (voltage V, temperature °C, current A, power W,
humidity %, GPS lat/lon+alt, etc.). Channel 1 is labeled 'device'
(repeater's own vitals). Refresh + updated-ago label; timeout renders
inline with a retry button (exercised for real on a 2-hop repeater —
first attempt timed out, retry succeeded with 2 channels).
Backend: repeater_req_telemetry in device_manager (serialized under
the repeater lock; the old name-only request_telemetry stays for
sensor nodes) + login-gated GET /api/repeaters/<pk>/telemetry.
GPS values arrive as {latitude, longitude, altitude} objects from
meshcore 2.3.7 — formatter handles both object and array forms.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First management tool. The Status tile auto-fetches on open and shows
a compact three-section table (not MO's oversized tiles):
- System: battery %/V (linear 3.3-4.2 V estimate), uptime, repeater
clock, queue length, debug/error events
- Radio: last RSSI/SNR, noise floor, TX/RX airtime
- Packets: sent + received (flood/direct split), duplicates, RX
errors, channel utilization computed client-side as
(tx_air+rx_air)/uptime (matches the firmware's own 10.09% reading)
Refresh button + "updated Ns ago"; errors render inline with retry.
Backend: GET /api/repeaters/<pk>/status and /clock, both gated on an
existing login session (401 need_login) to fail fast instead of a
2-min timeout. Clock loads as a follow-up request so the table
appears immediately, then the clock row fills in.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The fullscreen-modal override (@media max-width:576px) that zeroes the
default .modal-dialog 0.5rem margin enumerates modal IDs by hand and
omitted #repeatersModal, so on narrow windows the My Repeaters modal
kept an 8px top/left gap that Contact Management did not. Added
#repeatersModal to the three override selector lists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/repeaters/manage?pubkey=... — per-repeater management panel opened
automatically after login from the My Repeaters list:
- header card: name, shortened pubkey with copy, current path,
location, ADMIN/GUEST badge from the captured login session
- tools grid (Status / Telemetry / Neighbors / CLI / Settings /
Actions) with pane placeholders; CLI+Settings+Actions are locked
for guest logins (firmware accepts text CLI from admins only)
- auto-login with the saved password when the in-memory session is
gone (e.g. after app restart), password-modal fallback with retry
- REST: GET /api/repeaters/<pk> (merged entry + session state),
GET .../session, POST .../logout
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reload/stop tears old MQTT clients down on purpose; the disconnect
callback was logging 'lost connection ... auto-reconnecting' and
recording last_error for every config save. Mark clients as stopping
before teardown and return early in the callback.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Observer-owned daemon thread checks every 10 minutes whether
advert_interval_hours has elapsed since the persisted last-advert
timestamp and sends a flood advert via the existing DeviceManager
wrapper. Timestamp persists across restarts so redeploys don't
re-advertise early; failures and disconnected device skip the send
without touching the timestamp.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New Observer tab: master switch, IATA location code, flood advert
interval, live status line with packet counters, broker list with
connection badges (green/red + error tooltip), enable switches and a
stacked add/edit modal (blank password on edit keeps the stored one).
Live badge/counter updates via the observer_status socket event.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/api/observer/settings (GET/POST, partial updates, IATA + advert
interval validation), /api/observer/brokers CRUD (passwords never
returned; omitted password stays, empty string clears) and
/api/observer/status. Every mutating endpoint hot-reloads the
ObserverManager so config changes apply without a restart.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DeviceManager hands device identity (name/pubkey/self_info/device_info)
to the observer after connect and feeds every RX_LOG_DATA packet into
handle_raw_packet() before the GRP_TXT echo gate, wrapped in its own
try/except so observer failures can never affect chat. create_app()
builds the ObserverManager and links it with the DeviceManager.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New app/observer.py: ObserverManager owns per-broker paho-mqtt clients
(own network threads, QoS 0, LWT + retained status) and the packet
fan-out. Packet decode/hash/format functions are ports of upstream
meshcore-packet-capture including its legacy path fallbacks and
unknown-version quirk; validated against 10 live packets from the
reference observer network (hash/route/type/len all byte-identical).
Adds paho-mqtt==2.1.0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New observer_brokers table (name/host/port/credentials/TLS flags) plus
Database CRUD mirroring the analyzers pattern. Groundwork for the MQTT
packet-capture observer; no behavior change yet.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Letsmesh Analyzer is now a regular analyzers row, seeded once via an
app_settings flag on first startup. It can be renamed, disabled, or
deleted like any user entry. The frontend drops the special built-in
pseudo-row and the built-in slot in the chooser.
Click resolution updated: 0 enabled → toast "No analyzer configured";
1 enabled → open directly; multiple with a default → open default;
multiple without default → chooser.
The row switch and the edit modal now show "Enabled" (checked = active),
which matches intuition. The DB column remains is_disabled so no data
migration is needed — the UI simply inverts the value on read/write.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Enter on a phone's virtual keyboard now inserts a newline instead of
sending, in both group chat and DM. Detected via the pointer:coarse
media query - no user-facing setting, since accidental sends from a
mistapped Enter were the actual complaint. Desktop keeps Enter-to-send.
.dm-content was missing white-space: pre-wrap, unlike .message-content
in the group chat, so multi-line DMs collapsed to one line on the
sender's own screen even though the recipient saw them correctly.
loadMessages() called updateStatus('connected') on every successful
/api/messages fetch -- a DB read that succeeds regardless of whether
the meshcore device itself is connected. Same conflation in dm.js,
which marked 'connected' on Socket.IO transport connect (browser <->
server), unrelated to device <-> server connectivity. Together these
silently overwrote the real status (e.g. an actual device disconnect)
back to "Connected" on the very next channel switch or auto-refresh.
Also fixed the 'device_status' socket handler in app.js, which built
its own markup against a `#connectionStatus` element that doesn't
exist in any template (dead code since introduction) instead of
calling the shared updateStatus() against the real `#statusText`
element. Added a 60s polling fallback via loadStatus() in case a
push is ever missed on a long-lived tab.
Verified locally: ran the app against no real device (always
disconnected) and confirmed via Playwright that the badge now stays
correctly "Disconnected" through repeated loadMessages() calls,
instead of flipping back to "Connected" as before the fix.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A forced reconnect can fail without raising (e.g. self_info comes back
empty), leaving _connected False. The watcher loop only re-checked
staleness while _connected was True, so a single failed reconnect
attempt silently stopped all further healing forever — nothing else
retries non-BLE transports. Combined with /health/strict treating
"not connected" as OK (so the device stays connected forever), the
container needed a manual restart to recover.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The MemoryLogHandler broadcasts every record to the /logs namespace, but
with async_mode='threading' Socket.IO falls back to long-polling. Each
polling request is logged by werkzeug, the broadcast wakes the pending
poll, the client immediately re-polls, and the cycle repeats — producing
10+ requests/sec per open System Log tab. Filter werkzeug access logs
for /socket.io/ and /api/logs/ paths so neither the buffer nor the
broadcast trigger the loop.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>