Add a RepeaterConfigPoller that reads flood limits, timing parameters,
interference threshold, RX gain, AGC interval and region once a day,
inside a window that opens at 03:00 local time.
These values are configuration, not telemetry. No binary request returns
them, so each one costs its own CLI round trip. Putting them on the
15-minute statistics schedule would add ten round trips per poll for
values that did not change, and would lengthen exactly the window that
1.24.3 closed: a longer poll is likelier to be cancelled by outgoing
traffic, and a cancelled poll writes no record.
Readings go to their own append-only JSONL archive, so the statistics
record schema stays untouched. Both pollers share the one cancellable
task slot, so they can never occupy the radio at the same time.
CLI replies arrive as CONTACT_MSG_RECV with text type CLI_DATA and are
now dropped in on_contact_msg instead of appearing in the message list
as a direct message nobody sent.
The "Polling…" message raised by the Poll now button in the REPEATERS
panel stayed on screen indefinitely.
Root cause
The notification used type='ongoing', which Quasar renders with a timeout
of zero. Such a notification is meant to be dismissed programmatically by
the code that raised it, but the poll runs in the worker thread and the
panel never holds a handle to dismiss it. The message therefore remained
visible regardless of whether the poll succeeded or failed. Every other
panel in the project uses a typed notification with an explicit timeout;
this one was the exception.
Fix
The notification is now type='info' with a 3 second timeout, matching the
convention used elsewhere in the GUI.
Presentation only. The poll itself was unaffected — the message was
stale, not the polling.
feat(repeater): poll repeater statistics on a configurable interval
Configured repeaters are logged into, queried for their status and
logged out again on a configurable interval. Raw values are archived
with the moment of each poll, so battery voltage can be tracked over
multiple days.
New modules
- services/repeater_config_store.py — per-device configuration in
~/.meshcore-gui/repeaters/<device>_repeaters.json (directory 0700,
file 0600). Holds the full 32-byte public key, display name, login
password, poll interval and enabled flag. A key that is not 64 hex
characters is rejected: the meshcore library refuses a prefix for
login, status and logout requests. An absent password means "not
configured", an empty string is a valid credential.
- services/repeater_stats_archive.py — append-only JSONL at
~/.meshcore-gui/archive/<device>_repeater_stats.jsonl, one record per
poll, retention through the existing daily cleanup task.
- services/repeater_poller.py — send_login_sync, req_status_sync,
send_logout. The sync variants are used because plain send_login and
send_statusreq return as soon as the frame is on the wire and say
nothing about the outcome.
- gui/panels/repeater_stats_panel.py — read-only REPEATERS panel showing
the latest values, the age of the last successful poll and the last
error per repeater.
Changed
- config.py: VERSION 1.23.0, REPEATERS_DIR, REPEATER_POLL_ENABLED,
REPEATER_POLL_INTERVAL (900 s), REPEATER_POLL_CHECK_INTERVAL,
REPEATER_LOGIN_TIMEOUT, REPEATER_STATUS_TIMEOUT,
REPEATER_STATS_RETENTION_DAYS.
- ble/worker.py: _main_loop runs the repeater poll alongside the existing
periodic tasks, following the same interval-guarded pattern;
_cleanup_old_data applies retention to the new archive.
- __main__.py, gui/dashboard.py, gui/panels/__init__.py: wiring.
Behaviour
- At most one repeater is polled per check, each with its own start
offset within the interval, so two repeaters are never queried back to
back. Two repeaters on the default interval land ~7.5 minutes apart.
- A failed login, an absent status response or an exception is recorded
as a poll attempt with ok=false and a reason, and blocks neither the
other repeater nor the application. Logout is always sent once a login
has been attempted, including after a timeout — a login that returned
no confirmation may still have succeeded on the far side.
- Values are stored exactly as reported: no scaling, rounding or
averaging. Battery voltage keeps the unit the firmware returns.
- The password is reachable through a single method that only the poller
calls. Objects handed to the GUI have no password field, and no
password reaches the archive, the log or any error message.
- Polling stays idle until a repeater is configured, so an installation
without a configuration file behaves exactly as before.
No change to the archive JSON schema, the public REST API or any
existing public method signature. Worker and dashboard constructors
gained optional keyword arguments only.
WHAT
- MessagesPanel.set_active_channel() now points the send selector at the
active channel through the new _sync_send_channel() helper
- New _send_channel_pending flag, raised only on an actual channel change
- VERSION 1.22.3 -> 1.22.4
WHY
The drawer submenu filtered the list and rewrote the header but left the
selector alone, and update_channel_options()'s corrective branch only
fires for a value no longer in the options - Public always is. Viewing
#noodnet-ov and pressing Send therefore published on Public.
NOTES
The pending flag is required: update_filters() re-invokes
set_active_channel() every 500 ms, so an unconditional sync would
overwrite a manual choice twice per second. It also lets the sync retry
once options are loaded, which matters on page load with ?channel=N.
No public signature changed.
WHAT
- route_page: _default_reply_channel() derives the reply select default
from msg.channel instead of the first entry in the channel list
- models: new Message.date field, now_date() and display_timestamp();
format_line() and incoming()/outgoing() use them
- message_archive: persist "date" alongside "time" and "timestamp_utc"
- room_server_panel + route_page info header use display_timestamp()
- VERSION 1.22.2 -> 1.22.3
WHY
The route page exists to show one specific message, so that message —
not the ordering of the channel list — is the correct source for the
reply default. The date could not be fixed at the render site because
Message had no date at all: now_timestamp() yields HH:MM:SS and the
date lived only as timestamp_utc inside the archive layer, unread by
from_dict(). A first-class field plus a single formatting helper fixes
the cause and keeps the format in one place.
NOTES
No breaking changes. The archive schema gains one additive field;
pre-1.22.3 entries have their date derived from timestamp_utc
(converted to local time) and fall back to time-only when absent.
Message.incoming() gained an optional keyword-only date parameter.
Message display lines are ~11 characters wider.
fix(packet_decoder): brute-force channel resolution when hash lookup fails
ChannelCrypto.calculate_channel_hash() and the MeshCore firmware compute
different channel identifiers for the same secret, causing _hash_to_idx to
return None for all hashtag-channel messages. Fallback: try each registered
key individually via a single-key keystore. First valid decryption wins.
Result cached in _hash_to_idx for O(1) resolution on subsequent packets.
fix(events): channel-agnostic sentinel prevents cross-channel duplicates
on_rx_log now marks '*' in DualDeduplicator after storing a message.
on_channel_msg checks this sentinel and suppresses storage regardless of
channel_idx or message_hash differences between the two library systems.
Fixes #mc-radar messages appearing in #weather and vice versa.
fix(events): secondary path-cache keyed by content for hash-mismatch
_path_cache keyed by meshcoredecoder hash; CHANNEL_MSG_RECV carries meshcore
hash (different value). Added _path_cache_by_content ("sender:text[:100]")
as fallback so path_hashes are recovered when the two hashes disagree.
fix(commands,cache): remove stale channel key after del_channel reindex
Cache entry for old_idx not removed after slot move, causing same channel
to appear twice. New DeviceCache.remove_channel_key(idx) called after each
move. asyncio.sleep(0.5) added before re-discovery to let device settle.
feat(channel_panel,commands,dashboard): channel edit — move/reindex support
First channel-edit capability in the GUI. New '↕️ Move / Reindex' mode in
Channel Manager dialog. Source channel selected from dropdown, target index
in number field. ↕ button inline with 🗑 in Messages and Archive submenus.
_cmd_move_channel reads secret from cache or device, writes new slot, clears
old slot, updates cache atomically, triggers re-discovery with settle delay.
WHAT: New + Add Channel button in the Messages submenu opens a dialog
supporting three modes — Hashtag (key derived from name), Private New
(random key + QR export), Private Existing (paste hex key).
WHY: Channels could only be added via firmware/external tools. The new
dialog covers all user scenarios without changing the BLE worker or
existing panels.
NOTES: Requires `qrcode[pil]` for QR rendering (graceful degradation if
absent). Channel re-discovery is triggered automatically on success.
WHAT: New BotPanel replaces the BOT checkbox in ActionsPanel. Interactive
channel checkboxes (from live device channel list) replace the hardcoded
BOT_CHANNELS constant. Private mode restricts replies to pinned contacts only.
BotConfigStore persists settings per device to ~/.meshcore-gui/bot/.
WHY: Bot configuration was scattered (toggle in Actions, channels in code).
A dedicated panel and config store aligns with the BBS panel/BbsConfigStore
pattern and enables private mode without architectural changes.
NOTES: ActionsPanel.__init__ signature simplified (set_bot_enabled removed).
create_worker accepts pin_store kwarg (backwards compatible, defaults to None).
_abbrev_table used a list comprehension inline inside a generator
expression filter. In Python 3, list comprehensions have their own
scope, so the loop variable 'cu' was not visible to the outer 'if'
condition — causing a NameError on every !h / !help DM command.
Extract the comprehension to a local variable 'cats_upper' so both
the iteration and the filter operate on the same pre-built list.