25 Commits
Author SHA1 Message Date
pe1hvh ce6d8f8b9b docs: bring documentation in line with changelog up to 1.24.5
WHAT
- README: add 8.3 Repeater Configuration Read; document poll retries,
  traffic priority and cancellation in 8.2; add repeater, channel-sort
  and API keys to the configuration table; list rxlog.jsonl and
  repeater_config.jsonl; update 9.8, 9.16, 10, 14.2 and roadmap
- MULTI_INSTANCE: complete per-device file table and cleanup commands
- FEATURE_MESSAGE_PERSISTENCE: document the RX log JSONL stream,
  align example records with the archive fields
- MeshCore_GUI_Design.docx: v5.11.0 -> v6.0.0 for application 1.24.5
- CLAUDE.md: replace references to non-existent INSTALLATIE.md and
  TROUBLESHOOTING.md with README sections
- Fix stale defaults (MAX_CHANNELS, RXLOG_RETENTION_DAYS,
  BOT_DEVICE_NAME) and file paths (rooms, pins)
- Bump version 1.24.4 -> 1.24.5

WHY
Documentation lagged behind the code: most documents dated from 1.22.2,
the design document from 1.9.0, while the changelog had reached 1.24.4.

NOTES
Documentation only; no code, schema or API change. CLAUDE.md/ADR-003
describe meshcore-ble-connect as the primary BLE path, the code does
not use it; left unchanged pending decision.
2026-09-29 15:07:22 +02:00
pe1hvh 8b544ad95e feat(repeater): read repeater settings nightly over the CLI
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.
2026-09-18 18:59:30 +02:00
pe1hvh 4abcb97fbc fix(worker): run repeater poll as cancellable background task WHAT Run _poll_repeaters() as an asyncio.Task instead of awaiting it in _main_loop(). The task is cancelled as soon as has_pending_commands() reports queued work, and a new poll is only started when the queue is empty. RepeaterPoller.poll_due() catches the CancelledError, resets the repeater's _next_due to now and re-raises, so a cancelled poll writes no archive record and is retried on the next tick. Adds SharedData.has_pending_commands() with its SharedDataWriter protocol entry, and REPEATER_POLL_CANCEL_TIMEOUT (default 5.0) to bound the logout cleanup of a cancelled poll. Corrects the comment on REPEATER_POLL_MAX_ATTEMPTS, which still claimed a poll blocks the loop. WHY _main_loop() awaited the poll in the same iteration as process_all(), so a repeater poll blocked command dispatch for its full duration.
fix(worker): run repeater poll as cancellable background task

WHAT
Run _poll_repeaters() as an asyncio.Task instead of awaiting it in
_main_loop(). The task is cancelled as soon as has_pending_commands()
reports queued work, and a new poll is only started when the queue is
empty. RepeaterPoller.poll_due() catches the CancelledError, resets the
repeater's _next_due to now and re-raises, so a cancelled poll writes no
archive record and is retried on the next tick.

Adds SharedData.has_pending_commands() with its SharedDataWriter protocol
entry, and REPEATER_POLL_CANCEL_TIMEOUT (default 5.0) to bound the logout
cleanup of a cancelled poll. Corrects the comment on
REPEATER_POLL_MAX_ATTEMPTS, which still claimed a poll blocks the loop.

WHY
_main_loop() awaited the poll in the same iteration as process_all(), so
a repeater poll blocked command dispatch for its full duration. With the
1.24.2 defaults an unreachable repeater occupies 5 x 30 s login timeout
plus 4 x 5 s retry delay, roughly 190 s. A bot reply queued in that
window was only transmitted once the poll gave up, and a restart within
the window discarded it because the command queue lives in memory.
Confirmed in journal meshcore-gui-ttyUSB1.service of 2026-09-06: replies
queued at 09:33:46 and 09:35:23 were both sent at 09:36:25, the second
the poll ran out of attempts.

Statistics collection has no deadline; a bot reply does. Shortening the
retry budget would only shorten the window, not remove it.

NOTES
No breaking changes. has_pending_commands() is additive. Archive schema
unchanged, but no record is written for a cancelled poll, so the number
of polls per day can be lower than the interval suggests. A repeater on a
busy channel may go longer without a measurement — the intended trade-off.

Bumps version to 1.24.3.
2026-09-06 10:09:19 +02:00
pe1hvh fd3d0d2f35 fix(repeater): retry failed stats poll up to a configurable limit
WHAT
Repeater statistics polls now retry within the same poll instead of
recording a failure after a single session. REPEATER_POLL_MAX_ATTEMPTS
(default 5) and REPEATER_POLL_RETRY_DELAY (default 5.0) control the
budget. The login/status/logout sequence moved into a private
_attempt_session(); _poll_one() drives the loop and writes exactly one
archive record carrying the new "attempts" field and the last error.
A missing password is not retried. Version bumped to 1.24.2; the stale
__version__ = "5.0" in __init__.py was aligned with config.VERSION.

WHY
Polls fail at irregular intervals with no established root cause. This
is an explicit workaround: retrying recovers readings now, while the
attempt count makes the underlying failure rate measurable so the real
cause can be identified from data instead of guessed at.

NOTES
No API or schema break — add_measurement() gained a keyword argument
with a default, and "attempts" is an added field that older records
simply lack. A poll blocks the worker loop for its full duration, so
worst-case command latency rises from roughly 60 seconds to roughly
three minutes for a repeater that never answers. Airtime rises
proportionally: up to five login/logout round trips per failing poll.
2026-09-02 08:05:13 +02:00
pe1hvh 5a736b512b fix(gui): dismiss the manual poll notification after three seconds 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.
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.
2026-09-01 05:00:57 +02:00
pe1hvh c3ab1cc844 fix(gui): pass repeater stores to the dashboard page handler
fix(gui): pass repeater stores to the dashboard page handler

The REPEATERS menu item and panel were absent from the dashboard while
polling ran normally: statistics were collected and archived, but nothing
was visible in the GUI.

Root cause
DashboardPage is constructed in two places. The instance built in main()
received repeater_config_store and repeater_stats_archive, but the one
the @ui.page('/') handler creates per browser session did not. With both
arguments defaulting to None, self._repeater_stats stayed None, and both
the menu entry and the panel container are guarded on that attribute — so
they were skipped without an error anywhere.

The worker receives its stores through main() and was unaffected, which
is why polling and archiving kept working and the failure was invisible
in the logs.

Diagnosis
On the affected tree, _repeater_config_store appeared four times in
__main__.py against seven in a correct one. The three missing
occurrences were exactly the ones the page handler depends on: the
module-level global, the global declaration in main(), and the keyword
argument in _page_dashboard().

Fix
- Declare _repeater_config_store and _repeater_stats_archive at module
  level and add them to the global statement in main().
- Forward both stores from _page_dashboard() to DashboardPage.

Impact
- The REPEATERS panel appears whether or not a repeater is configured; an
  empty panel names the configuration file to create.
- No change to polling, archiving or stored data. Archives written under
  1.23.0 remain valid.

Anything a page handler needs must be passed at that call site, not only
where the object is first built in main(). A store that reaches one of
the two construction sites fails silently.
2026-09-01 04:14:06 +02:00
pe1hvh dad42503f4 feat(repeater): poll repeater statistics on a configurable interval
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.
2026-09-01 03:32:40 +02:00
pe1hvh 39e9d81161 fix(worker): poll device message queue when notification is missed
WHAT
Add MSG_POLL_INTERVAL (config.py, default 30s) and
SerialWorker._poll_pending_messages(), which drains the device
message queue via get_msg() until NO_MORE_MSGS, independently of
the messages_waiting event.

WHY
meshcore 2.3.9.1 fetches messages only in response to
messages_waiting. When that event is missed the radio ACKs the
packet but no message event ever reaches the application, so the
message is neither archived nor displayed. Node resets do not
help: the trigger goes missing, not the queue.

NOTES
start_auto_message_fetching() stays active; the poll only picks up
what the event path leaves behind. Fetched messages dispatch as
ordinary message events, so dedup and archiving are unchanged.
Room Server history timestamps remain a separate open issue.
2026-08-31 10:17:55 +02:00
pe1hvh 4b6d3a2d78 fix(channels): keep cached channel names when discovery fails
WHAT
_discover_channels() recorded no distinction between a slot the device
reported as empty and a slot it never answered for; both ended as an
absent entry, and the result was written over the cached channel names
wholesale. A device that returned ERROR on every slot therefore reduced
the cache to {0: "Public"}, permanently — those names are the only
channel source at startup while CHANNEL_CACHE_ENABLED is off.

The scan now records answered slots in a ChannelDiscoveryResult and
merges into the cached names: answered slots win, unanswered slots keep
their cached entry. Stale-key cleanup follows the same rule, replacing
the len(discovered) >= 2 heuristic.

WHY
The existing safety net guarded only the key cleanup, not the name write
above it. Recording answered slots addresses the defect itself rather
than adding a second heuristic, and lets one rule serve names and keys.

NOTES
No breaking changes. Cache JSON schema unchanged; _discover_channels()
keeps its signature. New config key CHANNEL_DISCOVERY_ABORT_THRESHOLD
(default 3) replaces the hardcoded consecutive-error limit.
2026-08-31 08:01:56 +02:00
pe1hvh 16ed9f5469 fix(messages): sync send selector with active drawer channel
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.
2026-08-16 09:01:16 +02:00
pe1hvh af74caf766 fix(route): default reply channel to message channel, add date to timestamps
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.
2026-08-16 08:32:24 +02:00
pe1hvh cced48c10e patch: JSONL stream output for RX log alongside existing JSON archivef(#v1.22.1)
v1.22.1: JSONL stream output for RX log alongside existing JSON archive

Every received LoRa packet is now also written immediately as a single
JSON line to ~/.meshcore-gui/archive/<device>_rxlog.jsonl. This is an
append-only, unbuffered stream format that lets separate local services
(such as meshcore-watchlist) consume the RX feed in real time without
depending on the GUI's internal batched-JSON format.

- The existing <device>_rxlog.json is unchanged (60 s flush interval,
  atomic rewrite). The GUI, the public REST API and the domca.nl
  ingest continue to work without modification.
- Writes to the JSONL file are direct (no buffer), so end-to-end
  latency from radio reception to JSONL line is sub-second.
- A failure on the JSONL path is logged via debug_print and does not
  affect the buffered JSON archive — the two paths are independent.
- _cleanup_rxlog() now also rewrites the JSONL file to drop entries
  older than RXLOG_RETENTION_DAYS. Corrupt lines (e.g. a partial
  last line after a crash) are skipped during cleanup.

No BLE/worker changes, no public REST API changes; SharedData and the
BLE command pipeline are untouched. Disk usage increases modestly
(one additional file per device, same retention window).

PATCH bump 1.22.0 → 1.22.1: purely additive, fully backwards-compatible.
2026-04-27 09:13:47 +02:00
pe1hvh 631176ddec feature: Drawer channel-list sort toggle(#v1.22.0) 2026-04-21 20:36:25 +02:00
pe1hvh 8fc1e78a05 Backup 2026-04-21 00:14:25 +02:00
pe1hvh 8d9723320f bugfix: Public API get_messages_payload() now exposes sender_pubkey and path_names so clients no longer need to re-resolve path hashes from the 1-byte prefix.(#v1.20.2)
get_messages_payload() built each item dict with sender, text, timestamp, hops and path_hashes, but silently dropped the already-resolved sender_pubkey and path_names fields that BleEventHandler writes to every archived message (see _resolve_path_names() in ble/events.py and the archive schema in services/message_archive.py lines 135–137).

Downstream consumers were therefore forced to re-resolve path hashes themselves using only the 1-byte path-hash prefix — a lookup that collides heavily in networks with more than ~256 nodes and yields the wrong repeater name, type and coordinates on nearly every hop.

The sender_pubkey omission had a similar effect on the sender column: clients could only match on display-name, which is ambiguous when two nodes share a name stem (e.g. NL-OV-ZWO-LGH-PD5WB vs the mobile variant NL-OV-ZWO-LGH-PD5WB-MOB).

Fix: added "sender_pubkey" and "path_names" to the item dict. Both fields are read straight from the archive — no new resolution logic is introduced, so there is no additional cost on the hot path. The response schema change is additive: existing clients that ignore unknown keys continue to work unchanged.
2026-04-19 17:23:20 +02:00
pe1hvh da3a868ec6 fix(bot): per-sender cooldown + empty-channel fallback (v1.20.1)
- Replace global _last_reply float with _last_reply_per_sender dict.
  A reply to one node no longer blocks all other senders for 5 s.
  LRU eviction keeps the dict bounded at 200 entries.

- _get_active_channels() now falls back to BotConfig defaults when
  the stored channel set is empty (user never saved a selection).
  Bot was silently deaf on first run despite the panel showing all
  channels pre-checked.

Closes: bot only replies to first sender in multi-node #test session.
2026-04-16 07:07:50 +02:00
pe1hvh 4a58a95cf0 fix: channel attribution, dedup, cache bugs + channel edit/move (v1.19.0)
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.
2026-04-06 10:23:24 +02:00
pe1hvh 8080bcc203 fix: Multibyte path hash support
### FIXED
- **Multibyte path hash support** (`ble/events.py`, `core/shared_data.py`):
  corrected docstrings in both `_resolve_path_names` methods that incorrectly
  described path hashes as "2-char hex strings". The actual contact lookup
  uses `startswith` matching, which is hash-size agnostic and correctly
  handles 1-byte (2 hex chars), 2-byte (4 hex chars) and 3-byte (6 hex chars)
  path hashes as introduced in MeshCore firmware v1.14.0. No functional code
  was changed — only the documentation was incorrect.
- **MariaDB schema** (`meshcore_schema.sql`): `meshcore_messages.path_hashes`
  column widened from `VARCHAR(128)` to `VARCHAR(255)`. The old limit caused
  silent truncation for paths longer than ~40 hops in 1-byte mode or ~25 hops
  in 2-byte mode. Migration is backward-compatible; existing data is unchanged.

### CHANGED
- `config.py`: version bump `1.17.0 → 1.17.1`.

### RATIONALE
- MeshCore firmware v1.14.0 (2026-03-06) introduced configurable path hash
  sizes (1-, 2- or 3-byte per repeater). Verification confirmed that
  `meshcoredecoder 0.3.2` already returns correctly sized hex strings via
  `_decode_path_len_byte`. The GUI path-resolution logic was already
  forward-compatible; only the docstrings and the MariaDB column width required
  correction.

### IMPACT
- No BLE handler, GUI panel, service or API endpoint modified.
- `meshcoredecoder` library unchanged; no pip update required.
- MariaDB migration: single `ALTER TABLE` statement, no downtime, no data loss.
2026-04-04 22:59:44 +02:00
pe1hvh 1275f78e36 feat(api): add public REST API for stats, nodes and messages
WHAT: Four read-only GET endpoints under /api/v1/ registered on the
NiceGUI/FastAPI instance via register_routes(_shared) in __main__.py.
Controlled by API_ENABLED in config.py (default: True).

WHY: Enables the domca.nl PHP collector to pull live mesh data over HTTP
without direct access to SQLite or SharedData internals.

NOTES: Private channel messages are unconditionally excluded in
public_api_service.py — filtering is server-side hard, no auth needed.
Channel rule: idx==0 (Public) or name.startswith('#') (Hashtag) = expose.
2026-04-04 14:17:07 +02:00
pe1hvh a62b8d1733 feat(channels): add hashtag and private channel management dialog
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.
2026-04-04 11:40:14 +02:00
pe1hvh 00d1739378 feat(bot): extract bot to dedicated panel with channel assignment and private mode(#v1.15.0)
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).
2026-03-16 16:48:16 +01:00
pe1hvh 8836d9dd6e fix(bbs_service): resolve NameError in _abbrev_table that crashed !h and !help(#v1.14.1)
_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.
2026-03-16 11:20:55 +01:00
pe1hvh 231f800b57 feat: DM-based BBS with channel-based access, multi-channel whitelist, short syntax(#v1.14.0)
Adds an offline BBS accessible via Direct Message to the node's own key.
Access is channel-based: anyone seen on a configured BBS channel is
automatically whitelisted for DM access. Channels stay clean.

- Multi-channel configuration: any combination of device channels can be
  selected; senders on any of them are auto-whitelisted
- Short syntax: !p <cat> <text> and !r [cat] alongside full !bbs syntax
- Category abbreviations computed automatically (shortest unique prefix)
- handle_channel_msg: bootstrap reply on channel + auto-whitelist sender
- handle_dm: DM entry point, checks whitelist, routes to post/read/help
- DM reply routed back to sender via command_sink
- SQLite message store with WAL mode and configurable retention
2026-03-14 21:02:03 +01:00
pe1hvh 2963e1c855 feat(bbs): DM-based BBS with channel-based access, multi-channel whitelist, short syntax 2026-03-14 20:58:56 +01:00
pe1hvh d8a7947c6b Initial clean code 2026-03-09 17:53:29 +01:00