222 Commits
Author SHA1 Message Date
agessaman 698f18dc6e fix(i18n): anchor the local catalog on local_dir_path and wire it everywhere
The local translation path defaulted to a bare `local/translations/`, resolved
against the process cwd and unrelated to `[Bot] local_dir_path`. Since
`local_dir_path` already selects where an operator's commands, service plugins
and config overlay live, the catalog belongs in that same tree. It now defaults
to `<local_dir_path>/translations`, resolved absolute against the bot root, so
relocating `local_dir_path` moves the catalog with it and the lookup no longer
depends on the working directory. An explicit `local_translation_path` still
wins.

Only the constructor at startup was passing the local path. `get_translator()`
builds and caches a translator per detected language, and `reload_config()`
builds a fresh one, and both were still constructing `Translator` with the
distributed path alone. So local overrides silently vanished from any reply that
used the sender's language, and did not survive a config reload. Both now pass
it, `reload_config()` republishes it alongside `translation_path`, and it is
snapshotted and restored on rollback. The DummyTranslator fallback sets it too,
since `get_translator()` would otherwise raise AttributeError there.

Collapsed `_deep_merge_translations` into the existing `_merge_translations`,
which already merged deeply with the primary winning. Rewrote that one to stop
mutating its input: it shallow-copied `fallback` and then recursed into the
shared sub-dicts, so merging a base language over English corrupted the cached
English catalog at every level below the first. Also replaced the two `print()`
calls on the error paths with logger calls.

Registered `local_translation_path` in the config schema next to
`translation_path`, and reverted a trailing-whitespace reflow that touched 84
lines of config.ini.example for a one-key addition.

Tests: local overlay (single-string override, added keys, local-only catalog,
missing directory), the merge no longer mutating its fallback, the default
following `local_dir_path`, an explicit setting overriding it, the overlay
reaching `bot.translator` end to end, and a reload picking up a moved path.
2026-09-07 20:28:45 -07:00
Gerard Hickey 9f807c9260 feat(i18n): Add support for local translation files
Add the `local_tranlation_path` to the `Localization` section  of
the configuration file to allow one to specify a local translation
file outside the distributed translation files. This allows
translation files to be built for local commands without the
need of altering the distributed ones.

Signed-off-by: Gerard Hickey <hickey@kinetic-compute.com>
2026-09-04 21:50:02 -04:00
Adam Gessaman 1740ca8072 fix(mqtt): resolve reconnect storm and improve token renewal handling
This commit addresses several issues related to MQTT connections. It prevents MQTT brokers from entering a reconnect storm by ensuring that the packet-capture watchdog only triggers a reconnect when necessary, avoiding duplicate CONNACKs and spurious disconnects. Additionally, it ensures that renewed MQTT auth tokens are properly enforced by implementing a clean reconnect process.

New configuration options are introduced: `mqttN_keepalive` for setting the PINGREQ interval per broker, and `mqttN_jwt_reconnect_on_renew` to control reconnect behavior after token renewal. Documentation has been updated to reflect these changes and to clarify the importance of unique client IDs for brokers in the same cluster.
2026-08-28 22:13:42 -07:00
agessaman 7debb92e9e feat(template): add a hops_min filter for gating on route length
{firstlast_distance|prefix_if_nonempty: | F/L Dist: } prints "| F/L Dist: N/A"
on a direct message: the distance helpers return the literal "N/A" when there
is no path to measure, prefix_if_nonempty only asks whether the value is
non-empty, and "N/A" is. Suppressing that needed a gate, and pathbytes_min was
the only one available—so it was being used as a stand-in for "did this
message take any hops", which is not what it asks. It asks how the path is
encoded, so pathbytes_min:2 also discards a one-byte multi-hop path whose
distance is real and measurable.

hops_min:N asks about the route itself. hops_min:1 drops a clause on a direct
message and nothing else:

    hops_min:1   pathbytes_min:2
    direct         cleared      cleared
    1-byte 2 hops  kept         cleared   <- the difference
    2-byte 2 hops  kept         kept
    unknown        cleared      cleared

An unknown hop count clears the value: a gate that cannot confirm the route
should suppress rather than guess, matching pathbytes_min.

The hop-count logic moves to utils.message_hop_count so the filter and
BaseCommand.get_hops_display_values share one implementation instead of two.
It returns None for unknown rather than 0, since a gate must not read
"cannot tell" as "direct".
2026-08-25 10:03:30 -07:00
agessaman d2b83481f7 feat(placeholders): expose {packet_hash} through the placeholder service
Adds the 16-char MeshCore packet identity hash to the three formatters that
render user-facing templates: [Keywords] responses, the test command's
response_format, and the path command's reply_prefix. It is read only from
routing_info, which is attached solely from an RF packet correlated to the
message, so a hash from an unrelated transmission is never shown. Missing,
empty, and the 0000000000000000 error sentinel all render empty.

get_packet_hash_placeholder returns "" rather than a False sentinel. Empty
gives byte-identical output on every path—prefix_if_nonempty already tests
falsiness and str.format renders "" as nothing—whereas False would print
the literal string "False" into a mesh reply from any consumer of
get_standard_placeholder_fields that formatted it directly.

TestCommand.format_response now builds on get_standard_placeholder_fields
instead of duplicating it. That also honors the extra argument its docstring
already promised but silently ignored, and picks up the path-string hop
fallback, so {hops} reads "0" rather than "?" for a message whose hops and
routing_info are both unset but whose path says "Direct (0 hops)".

Documents the placeholder in config.ini.example, the default config that
core.py writes on a fresh install, the README and docs/path-command-config.md.
The generated config was also missing {elapsed}; added that too.
2026-08-25 09:16:09 -07:00
agessaman 133a3bb595 docs: correct prefix API guidance and document pipx installs
Two documentation gaps behind open questions.

The [External_Data] repeater_prefix_api_url comment claimed that leaving
it empty "disables prefix command functionality" (#70). That is not what
happens: the prefix command answers from the bot's own database of heard
repeaters, and the API only augments it with node counts from a wider
dataset. The wrong comment is a plausible reason the question was asked
at all. Corrected, and the JSON contract is now documented in the command
reference for anyone serving their own endpoint, since map.w0z.is is
defunct and has no drop-in replacement.

Also documented the pipx path (#222). The installer already grew a
virtualenv in July, which covered the PEP 668 half of that report, but
the unanswered part was where config.ini, the database and local/ live
under a pipx install. They are all resolved relative to the directory
containing config.ini, which means a bare `meshcore-bot` picks up
whatever is in the current directory — so the guidance is to pass an
absolute --config. Both console scripts are already smoke-tested from an
installed wheel in CI, so this path is supported rather than incidental.
2026-08-21 23:00:55 -07:00
agessaman a764186eb2 feat(scheduler): airtime guards for {cmd:...}, plus missed docs
Two guards on command placeholders in scheduled messages, neither
configurable, because both exist to protect a shared medium:

A 15-minute floor. A schedule containing {cmd:...} that fires more often
is rejected at startup with an error rather than quietly running slower.
The interval is sampled and measured by the tightest gap between
firings, so "0,1 * * * *" is correctly treated as every 60 seconds and
not as hourly. Schedules without a command placeholder are unaffected.

The command's own cooldown_seconds now applies to a render. Scheduling
is not a way around the rate a command was configured to run at. The
execution is recorded before the command runs, matching execute_commands,
so a slow or failing render cannot be retried straight past the cooldown.

Also fills documentation gaps from the preceding commits:

- --install-extras was only in the script's own --help; documented in
  service-installation.md (including alongside --update-venv) and
  upgrade.md.
- weather-service.md documented weather_alarm's once-a-day scheduling
  with no route to more than one forecast a day, which is exactly what
  people go there looking for. It now points at {cmd:wx ...} and notes
  that sunrise/sunset still belong to weather_alarm.
2026-08-21 21:33:54 -07:00
agessaman b0148506a6 feat(scheduler): broadcast any command's output with {cmd:...}
A scheduled message can now embed the reply of any bot command:

    [Scheduled_Messages]
    0 6,12,18 * * * = Public:{cmd:wx Seattle}

The schedule side was never the missing piece — [Scheduled_Messages]
keys have been 5-field cron with @presets for a while, so multiple
times, intervals and @hourly were all already expressible. What was
missing was any way to get a service's output into a scheduled message:
placeholders were limited to mesh info (contact counts). That gap is
why services were growing their own schedule parsers.

CommandManager.render_command_output runs a command for its text alone.
MeshMessage.capture_sink makes that safe: when set, send_response
collects the reply and transmits nothing, checked before the
_last_response bookkeeping so a background render cannot overwrite the
response captured for a real user's command.

Degrades quietly in every failure mode — unknown, disabled, admin-only,
non-renderable, timed out or silent commands expand to nothing, so raw
{cmd:...} text is never put on the air, and a message left empty is not
sent at all. Command output is not re-scanned, so a reply containing
{cmd:...} cannot recurse. Commands that transmit directly rather than
returning text (announcements) are refused outright, since rendering
them would broadcast for real.

Bounded by [Bot] scheduled_command_timeout_seconds (default 30).
2026-08-21 21:25:16 -07:00
agessaman 4d8624494c feat(path): expose {path_distance} in the path command reply prefix
Total path distance was already a solved problem here: the test command
computes it and exposes {path_distance} through the piped-template
engine, with pipe filters and docs. The path command just had no way to
reach it, since reply_prefix went through plain str.format.

Rather than add a second distance implementation, this reuses what
exists. BaseCommand.get_standard_placeholder_fields now returns the
shared placeholder set so subclasses can extend it, the path command
renders reply_prefix through format_piped_template with an added
{path_distance}, and the sum itself uses utils.calculate_distance over
the nodes the path command has already resolved.

Distance covers sender -> each hop -> bot and is deliberately blank when
the chain cannot be measured end to end (unresolved hop, prefix
collision, missing or 0,0 coordinates, unknown sender, no configured
bot position), so a partial sum is never reported as the real distance.

Because the prefix now supports pipe filters, an empty value takes its
label with it: {path_distance|prefix_if_nonempty:📏 }

Supersedes #198, which added a parallel haversine, a show_path_distance
flag, and a distance_traveled key across 10 locales.
2026-08-21 21:10:03 -07:00
agessaman 25671f28de Merge pull request #241 from ajquick/dev
Add configurable PacketCapture observer name

Adds an optional [PacketCapture] observer_name used as the MQTT `origin`
for packet and status reporting, letting the observer identity differ
from the connected MeshCore device name. Unset keeps the previous
behavior. The public key backing origin_id, authentication, and topic
resolution is unchanged.

CHANGELOG conflict resolved by keeping both Unreleased entries.
2026-08-21 20:33:42 -07:00
agessaman 5bdd6302c1 fix(airplanes): switch Airplanes API to adsb.lol default 2026-08-21 09:57:23 -07:00
AJ Quick 8ea806c074 Add observer_name option to config.ini.example
Added optional observer_name configuration for MQTT.
2026-08-08 15:37:32 +00:00
agessaman 61da191fef fix(config): correct typo in comment regarding max_response_hops 2026-08-07 14:38:43 -07:00
Adam Gessaman 9d6dc6fa2d Merge pull request #234 from agessaman/feat/neighbors-mqtt
Implement zero-hop neighbor discovery and improve cycle management
2026-08-06 21:03:36 -07:00
agessaman 34bfe807f7 refactor(neighbors): improve IATA handling and cycle management
Enhance the handling of the global IATA configuration to ensure that blank values are treated correctly, preventing unintended namespace pollution. Introduce methods for claiming and releasing the neighbors discovery cycle, ensuring that overlapping requests are managed effectively. Update the NeighborsCommand to utilize these new methods, improving the accuracy of cooldown management for users. Additionally, add tests to validate the new behavior and ensure that cooldowns are respected during busy and disabled states.
2026-08-06 11:04:15 -07:00
agessaman 035ec963b6 docs: clarify APScheduler cron usage and day-of-week numbering
- Updated `config.ini.example`, `command-reference.md`, and `configuration.md` to clarify the use of APScheduler for cron scheduling, emphasizing the difference in day-of-week numbering compared to Vixie cron.
- Added examples and notes to prevent confusion regarding the interpretation of cron expressions, particularly for users transitioning from Vixie cron conventions.
- Enhanced documentation to guide users in using the correct syntax for scheduling messages, ensuring better understanding and usability.
2026-08-04 20:33:26 -07:00
agessaman ebc66992dd feat(neighbors): zero-hop neighbour discovery in packet capture
Port the observer firmware's neighbours feature into the bot's packet capture
service, by way of meshcore-packet-capture (upstream PRs #42/#43). On a long
interval the bot asks which repeaters it hears directly and records each
confirmed link with its measured SNR.

This is the strongest link evidence the bot collects: a first-party RF
measurement between two full 32-byte public keys. Path inference works from
1-3 byte prefixes with no keys, and complete_contact_tracking.hop_count
over-claims zero-hop (800 claimed vs 68 corroborated on the live database).

modules/neighbors_discovery.py keeps upstream's public names so its fixes and
tests stay portable. Two deliberate divergences:

- No command_lock plumbing. _SerializedCommands in modules/core.py already
  serialises and paces every radio command, strictly more than upstream's
  reentrant lock did.
- neighbors_collect_scopes defaults off. Upstream's zero-hop scope probe
  relies on a neighbour not being a known contact; this bot tracks contacts,
  and for a repeater with no stored path the library reaches zero-hop by
  calling change_contact_path() then reset_path() -- mutating the device's
  contact table per neighbour. Scope requests also hold the radio lock for
  their whole round trip (~25s), stalling bot replies. The default cycle is
  one command plus a passive listen window, during which the bot stays
  responsive.

Evidence lands in neighbor_links and neighbor_observations (migration 22)
rather than mesh_connections, which cannot persist provenance. The viewer
exposes it as evidence=neighbors on /api/mesh/edges and a Neighbours Only
mode on the mesh page, with populated public keys and real SNR; confirmed
neighbours also relabel edges in the combined view and count as
provenance-trusted when framing the initial map.

neighbors_enabled is the single switch. Every enabled broker publishes once
it is on (mqttN_neighbors defaults true; set false to hold one back). The
topic derives from each broker's packets topic with the last segment swapped,
so a templated broker gets meshcore/{IATA}/{PUBLIC_KEY}/neighbors -- the
topic the firmware uses -- instead of an unrelated flat one. A derived
location-routed topic is skipped with a warning when no iata is set, rather
than publishing into meshcore/XYZ/... on a shared namespace. Snapshots are
non-retained: heard_secs_ago is relative to publish time, so a retained copy
would read as current days later.

Also adds a DM-gated `neighbors` command (the 12h interval floor makes
waiting for the scheduler impractical), which acks immediately and reports in
a second message once the window closes.

Requires meshcore >= 2.3.8 for send_node_discover_req / req_regions_sync.
2026-08-04 19:24:18 -07:00
agessaman 40307bd8cb feat(mesh): enhance mesh graph performance and caching
- Implemented caching for multi-byte mesh aggregation, allowing concurrent requests to share computation and reducing CPU load.
- Updated the web viewer to coalesce live edge events and refresh the mesh graph every 30 seconds, improving responsiveness on busy networks.
- Adjusted logging behavior in the web viewer to respect the configured log level, ensuring efficient logging without excessive duplicate entries.
- Added configuration options for mesh graph caching duration, enhancing user control over performance settings.
2026-07-30 13:16:16 -07:00
agessaman b8b1647b27 feat(retention): enhance data retention and resource limits for Raspberry Pi
- Implemented chunked deletion for data retention, allowing for smoother cleanup processes without monopolizing SQLite's writer lock.
- Configured retention settings to delete in batches with pauses, improving performance on SD-card installations.
- Updated Linux service installers to allocate 1GB of memory and 200% CPU, providing better resource management for Raspberry Pi workloads.
- Added detailed configuration examples for Raspberry Pi in documentation to guide users on optimal settings.
2026-07-30 08:06:21 -07:00
agessaman 07d6737008 feat(graph): optimize mesh graph persistence and data retention
- Updated mesh graph path splitting and aggregation to run in SQLite, improving performance by avoiding Python materialization.
- Defaulted graph persistence to batched writes for new installations, reducing WAL churn and SD-card writes.
- Enhanced data retention to execute shortly after startup, independent of the nightly maintenance schedule.
- Added a table-specific index for `mesh_connections` to support window and retention queries, ensuring efficient data access.
2026-07-30 07:23:06 -07:00
agessaman 7e3ab04b53 feat(web-viewer): rebuild dashboard on a background snapshot
The landing page re-ran ~50 aggregate queries five times per load, then
repeated the whole sequence every 30 seconds forever — including in
backgrounded tabs. Against the live 1.44 GB database that was roughly 20
seconds of SQLite work per page load.

Move the work off the request path. A refresher thread in the viewer
process (which already runs migrations, so it works for a split-DB
install) writes two tables: daily_rollup, one row per local date, and
dashboard_snapshot, a single JSON row. A page load now reads one row.

Measured on the live database: first paint 6 requests -> 2,
/api/dashboard/summary p50 1.1 ms (304 in 0.8 ms), /api/stats 130 ms,
and a 0.32 s refresh once a minute in the background.

Make the numbers mean what they say:

- Window selectors are built from each source's retention. The page
  offered "30d" and "All" against tables pruned at 7 days, so three of
  four choices returned the same figure under a label that denied it.
- The incoming-packet chart reports its measured window instead of
  claiming 7 days for a table pruned at 3 — it sat beside a genuine
  7-day contacts chart inviting an invalid comparison.
- Days with no source data store NULL and render as gaps. Writing 0
  would put a fake cliff at every retention boundary.
- Signal metrics are stored as sums and counts, never means, so any
  window re-aggregates correctly.
- Delta chips compare the last two complete calendar days and say so;
  the headline above them is a rolling 24 hours.
- Unmapped role ordinals (type0..type15) bucket into "Unknown".
- SNR comes from message_stats, where it is populated on every row, not
  from complete_contact_tracking, where it is populated on 7%.

Kill the json_extract scans: packet_stream gains denormalized
route_type_name, payload_type_name, path_len and bytes_per_hop, written
at capture time. Aggregating those from JSON cost 3-6 s per query.
Existing rows convert a bounded batch per tick rather than in one
migration that would rewrite ~180 MB into the WAL and stall bot startup.
A partial index serves as the backfill worklist — without it the "any
rows left?" probe is a full scan costing 4.6 s per tick, and it costs
that after the backfill finishes, because finding nothing still means
reading everything.

Also: replace the per-contact hop-prefix scan with the existing bucketed
matcher and memoize the 7-day chunk set (264 ms -> 35 ms on a synthetic
100k-row database, regression-locked by a test); move the dashboard's JS
and CSS to static files, which removes the CSP nonce requirement for the
bulk of the page; and give cleanup_old_stats a future-timestamp guard,
without which rows dated 2103 are never older than the cutoff and so
live forever.

Deletes the orphaned /stats page, unreachable from the nav and rendering
stub charts that never populated. /api/stats stays as a shim with every
key name intact plus Deprecation and Sunset headers.

All schema changes are additive, so a downgraded codebase can read the
data; it would however need the new schema_version rows removed, since
MigrationRunner rejects versions it does not know.
2026-07-29 21:56:09 -07:00
agessaman f4587392db fix(config): update auto_manage_contacts default behavior and documentation
Changed the default setting for `auto_manage_contacts` from `false` to `device` across configuration files and updated related documentation. This ensures that the device handles auto-addition of contacts while the bot manages capacity. Adjusted comments and documentation to reflect this change for clarity and consistency.
2026-07-29 19:49:50 -07:00
agessaman 96c4a01788 fix(commands,clients): correct scheduling, parsing and formatting defects
Remaining findings from the same triage pass. None are security
relevant; each produces a wrong user-visible result.

- greeter: treat rollout_started_at as UTC. It is written by SQLite
  CURRENT_TIMESTAMP, but datetime.timestamp() read the naive value as
  local time, shifting the backfill cutoff by the host's offset (7h on
  PT). Users who posted inside that window were never marked as already
  greeted and could be sent a welcome they should not have received.

- greeter: write greeted_at in SQLite's own format. The rollout backfill
  used isoformat() while every other path used CURRENT_TIMESTAMP. "T"
  sorts above a space, so ORDER BY greeted_at interleaved the two
  formats wrongly, corrupting duplicate cleanup and the web viewer's
  recently-greeted list.

- greeter: let an empty `channels =` disable the command. BaseCommand
  reads an empty value as disabled-on-channels, but the greeter
  collapsed "key absent" and "key present but empty" into one fallback
  and kept greeting via monitor_channels.

- greeter: stop comma-splitting greeting text. channel_greetings split
  entries on ",", so "Public:Welcome to the mesh, {sender}!" was stored
  as "Welcome to the mesh" with the placeholder silently dropped. A
  fragment now starts a new entry only when the text before its first
  colon looks like a channel name, which keeps commas, URLs and clock
  times attached to the greeting they belong to.

- wxsim_parser: match condition abbreviations longest-first. Substring
  matching in dict order let RAIN shadow CHNC. RAIN, so five conditions
  lost their "chance" qualifier (rain, snow, drizzle, t-storm, and
  FAIR-P.C.).

- wxsim_parser: re-anchor "now" on each parse. current_date and
  current_year were fixed at construction and wx_command builds one
  parser at startup, so after a few days forecast dates rolled back a
  year and staleness checks read permanently true.

- thesportsdb_client: hold the rate-limit read/sleep/write under a lock.
  Concurrent callers read the same last_request_time, slept the same
  interval and fired together, bursting past the 2.1s throttle. This
  exposes an unrelated request fan-out problem in fetch_league_scores;
  filed in TODO.md rather than fixed here.

- alert_command: stop duplicating the first incident. The tail treated
  any single-line buffer as "header only" and appended incidents[0],
  but messages after the first carry no header, so a final chunk holding
  one incident had incident 0 pasted onto it. The same confusion inside
  the loop also let a second incident be appended past the 130-character
  limit.

- feed: reject non-positive poll intervals. -1 is truthy and was stored,
  and the poller's `now - last_check >= interval` then treats the feed
  as permanently due and re-fetches the URL every cycle; 0 was ignored
  while still reporting success. The web viewer, which is the primary
  editor, had no validation at all, and a JSON null there raised a
  TypeError that aborted the poll cycle for every feed rather than one.
  feed_manager falls back to the default for rows written before this.

- transmission_tracker: age out confirmed transmissions that have
  repeats. Cleanup removed only repeat_count == 0, so repeated records
  accumulated for the lifetime of the process, which matters on a Pi
  Zero. Repeat counts are already persisted to packet_stream, so the
  longer 30-minute retention loses nothing.

- mesh_graph: weighted-merge avg_hop_position when promoting an edge.
  Promotion overwrote the average with the single new observation, so an
  edge averaging 2.0 over 4 observations became 9.0 instead of 3.4 and
  skewed subsequent path scoring.

- multitest_command: show the path that ends exactly at the display LCP.
  The suffix helpers are correct in isolation; the loss happens in the
  cluster formatters, where _shrink_display_lcp refuses to shrink a
  single-token LCP and the trunk then rendered as "96 ┐", meaning
  "everything continues past here" and dropping the bare 96 route while
  the header still counted it. Now uses the file's existing "├ common"
  marker. Empty suffixes are also no longer handed to the nested
  renderer, which mapped them onto the same [] as a route ending at the
  inner LCP and drew a row for a route that did not exist.

- sports_mappings: stop shadowing seven unique team nicknames. In one
  flat dict a repeated key silently drops the earlier team, so hawks
  resolved to the NBA Hawks rather than the Seahawks alias it was added
  as, blazers to Kamloops rather than Portland, and rockets to Kelowna
  rather than Houston. First definition now wins for hawks, giants,
  jets, rangers, kings, blazers and rockets; every shadowed team keeps
  its unambiguous full-name alias. The 55 city and abbreviation
  collisions (chicago, sf, la, ...) are genuinely ambiguous and stay
  last-wins, now pinned by a test so a new collision fails loudly
  instead of passing unnoticed.
2026-07-28 20:08:57 -07:00
agessaman a5f7f711dd fix(config): close config-injection and credential-exposure holes
Four defects in how configuration is written and displayed. All are
reachable without authentication when web_viewer_password is unset,
which is an explicitly supported setup.

- ini_writer: reject sections, keys and values that cannot survive the
  round-trip. update_ini_values() wrote free text straight into the
  file, so a newline in any value ended the key's line and everything
  after it was re-parsed as INI on the next load. Saving a greeting of
  "Welcome!\n[Injected]\npwned = true" through the plugin settings
  endpoint created a real new section; a repeated section name bricked
  startup with DuplicateSectionError. The check lives at the writer
  because three separate callers reach it, and raises IniValueError so a
  bad payload is a 400 instead of a half-written file. DEFAULT is
  refused as well: its keys apply to every section, and
  ConfigParser.add_section('DEFAULT') raises.

- settings_store, web_viewer: persist to disk before mirroring into the
  in-memory config. The mirror ran first, so a value rejected by the
  writer stayed live in the running process despite never reaching
  disk. Both routes taking free-form input (the zombie and offline
  alert emails) had the same ordering. The zombie route also caught
  only OSError, so an IniValueError there escaped as a 500 rather than
  the intended 400.

- config_snapshot: redact Discord webhook URLs. Neither
  discord_webhook_urls nor the [DiscordBridge] bridge.<channel> keys
  matched any redaction rule, so --show-config and /admin/config
  printed live webhook secrets in full; anyone holding one can post to
  the channel. Telegram's api_token was already redacted. The
  bridge. prefix needs its own rule because the varying part is the
  channel name, leaving no fixed stem for the substring match.

- mqtt_weather, packet_capture: verify TLS certificates by default.
  Both called tls_set(cert_reqs=ssl.CERT_NONE) unconditionally, so the
  broker username and password sent immediately afterwards were
  readable by anyone able to intercept the connection.

BREAKING: brokers presenting self-signed certificates now fail to
connect until tls_insecure (mqtt_weather) or mqttN_tls_insecure
(packet_capture) is set to true. Both log a warning while enabled.
2026-07-28 20:07:58 -07:00
agessaman 9a24ed1bf2 chore: update documentation and improve configuration handling
- Added a tracked `LICENSE` file and updated `pyproject.toml` to include license metadata.
- Enhanced `CHANGELOG.md` with recent changes and clarifications.
- Updated `config.ini.example` and related documentation to reflect clamping behavior for numeric limits in `[Feed_Manager]`.
- Improved startup validation to suggest corrections for unknown and misspelled keys.
- Refactored geocoding and HTTP request handling to run off-thread, preventing event loop stalls.
- Added thread safety to cache management in geocoding functions to avoid race conditions.
2026-07-28 16:56:43 -07:00
agessaman 717f9a8ef3 feat(i18n): opt-in auto-detect sender language for replies
Reworked implementation of the multilingual-response feature (issue #218)
as a reusable, framework-level capability instead of logic bolted onto
HelloCommand.

- modules/lang_detector.py: keyword-first detection (reliable for the short
  greetings typical on mesh) with optional langdetect for longer text;
  results are constrained to languages that actually have a translation file.
- core.py: cache Translator instances by language (get_translator) so
  per-message switching never re-reads files, and expose available_languages()
  derived from translations/*.json. Store translation_path; keep cache/path
  consistent on config reload.
- base_command.py: task-local translator override via a ContextVar (safe under
  asyncio concurrency, no global mutation), plus detect_response_language() and
  a respond_in_sender_language() context manager any command can opt into.
- hello_command.py: build the reply inside respond_in_sender_language(); the
  only awaited call (send_response) stays outside the override window.
- Gated by [Localization] auto_detect_language (default false); langdetect is
  an optional [lang] extra, not a hard dependency.

Tests: unit coverage for the detector and the base-command wiring.
2026-07-28 12:44:53 -07:00
agessamanandClaude Opus 4.8 03a1d4056b Merge origin/dev into codex/p0-security-hardening
Resolve the feeds.html conflict by keeping the branch's XSS-safe DOM
construction of the feed-details view and re-adding dev's per-feed and
reset-all error buttons via addEventListener instead of inline onclick,
since /feeds is a nonce-CSP page where inline handlers are blocked.

Update tests/test_feed_manager_extended.py::TestPollFeedPosting to patch
the SafeUrlPolicy.validate_async path (this branch's SSRF refactor) rather
than the removed module-level validate_external_url symbol, matching the
idiom already used throughout that test file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 14:28:13 -07:00
agessaman c1c3ac2511 feat(feed-manager): introduce max_posts_per_check and enhance emoji handling
- Added max_posts_per_check to limit the number of items posted per check, defaulting to max_items_per_check for backward compatibility.
- Updated logic in FeedManager to process new items, ensuring that only the specified number of posts are made while examining a larger set of items.
- Enhanced emoji selection to prioritize per-item emojis from the API, falling back to heuristics based on feed names when absent.
- Introduced new truncation and substring functions for formatting messages, improving text handling in feed outputs.
- Added API endpoints to reset feed error counts globally or for individual feeds, enhancing error management capabilities in the web viewer.
2026-07-17 16:43:08 -07:00
agessaman 1f0ec48448 Harden feed, config reload, and wx async paths 2026-07-16 15:40:20 -07:00
agessaman 448dc39261 docs(config): document settings-UI keys in config.ini.example + drift test
Eight keys that plugins read and the web settings UI writes were missing
from config.ini.example, so config validation flagged them as unknown:
Path_Command confidence symbols, Alert_Command max_distance_km /
max_incident_age_hours, Wx_Command temperature_unit / wind_speed_unit,
and PacketCapture mqtt_enabled.

Adds a sync test so any future settings_schema key must be documented in
the example config (and thus known to validation) or CI fails.
2026-07-12 11:57:28 -07:00
agessaman c5bcfb3f26 feat(config): enhance connection configuration and documentation
- Updated `config.ini.example`, `config.ini.minimal-example`, and `config.ini.quickstart` to clarify connection type precedence and required keys for serial, BLE, and TCP connections.
- Added detailed comments and examples for each connection type, ensuring users understand how to configure their connection settings effectively.
- Introduced a new `[Service_Overrides]` section in `config.ini.example` for alternative service plugin implementations, improving extensibility.
- Enhanced documentation in `configuration.md` to reflect these changes and provide clearer guidance on connection options and templates.
2026-07-09 23:25:11 -07:00
agessaman 7e7279875c feat(packet_capture): decode packet payloads to MQTT and log
Add optional payload decoding to the packet capture service. GRP_TXT
channel messages are decrypted (sender/text), ADVERTs are parsed
(name/role/lat-lon), and a nested "decoded" object is attached to each
packet alongside the unchanged raw fields.

- Comprehensive channel key store: bot's configured radio channels plus
  decode_hashtag_channels, [Channels_List], decode_channel_keys, and the
  built-in default Public key.
- Publishing the decoded object to MQTT is off by default and
  configurable per broker via mqttN_include_decoded.
- Configurable packet-log rotation (off/size/time) for historical dumps.

The decoder lives in a standalone, dependency-free module
(modules/meshcore_payload_decode.py) so it can be shared verbatim with
the meshcore-packet-capture project.

Closes #197
Closes #35
2026-07-08 10:49:06 -07:00
agessaman 95b0601980 feat(command_prefix, config): enhance command prefix handling and documentation
- Updated `config.ini.example` to support single, multiple, and decorative command prefixes, improving flexibility in command invocation.
- Refactored `CommandManager` and `BaseCommand` to utilize new command prefix parsing logic, allowing for better handling of multiple prefixes and optional bare commands.
- Enhanced documentation to clarify command prefix configuration options and behavior, ensuring users understand how to set and use prefixes effectively.
- Added unit tests to verify the correct functionality of command prefix handling, including support for permissive and strict prefix modes.
2026-07-07 09:37:28 -07:00
agessaman 18f231b142 feat(config, command): add hops placeholders to path command responses
- Updated configuration examples to include new `{hops}` and `{hops_label}` placeholders for path command replies, enhancing response detail.
- Modified `BaseCommand` to support hop count retrieval and formatting, ensuring accurate display of hop information in responses.
- Enhanced unit tests to verify correct formatting of hop-related placeholders in response messages, improving overall command functionality.
2026-07-06 11:29:47 -07:00
agessaman b59fa9cec9 feat(config, docs): enhance rain command and stats collection configuration
- Updated the `[Rain_Command]` section in `config.ini.example` to include support for snow alongside rain, improving the command's functionality.
- Enhanced documentation for the rain command to reflect the new snow alias and clarify response behavior based on the selected keyword.
- Added a new `collect_stats` option in the `[Stats_Command]` section, allowing stats collection to be enabled independently of the user-facing command, with updated documentation to explain its behavior.
- Improved the web viewer documentation to clarify how stats are collected and displayed, ensuring users understand the configuration options.
2026-06-28 13:28:23 -07:00
agessaman d23ee5db9e refactor(config): remove custom syntax section from configuration files
- Deleted the `[Custom_Syntax]` section from `config.ini.example` and `core.py`, simplifying the configuration by removing outdated custom syntax patterns and examples.
- This change streamlines the configuration files, focusing on essential settings and improving clarity for users.
2026-06-28 08:55:25 -07:00
agessaman 24fdfb2370 feat(command_manager, help_command): improve command prefix handling and help command visibility
- Enhanced `CommandManager` to persist normalized message content, preventing prefix stripping from interfering with command matching.
- Updated `BaseCommand` to skip prefix handling if already normalized, ensuring all commands can be matched correctly.
- Modified `HelpCommand` to ensure unused commands are listed even when command statistics are present, improving user accessibility to all commands.
- Added unit tests to verify the correct behavior of command matching and help command functionality under various scenarios.
2026-06-28 08:42:58 -07:00
agessaman 6584800cb7 feat(command_manager, models): enhance flood scope handling for channels
- Introduced per-channel flood scope configuration in `config.ini.example`, allowing for more granular control over message routing.
- Updated `CommandManager` to normalize channel names and resolve flood scopes based on channel-specific settings, improving message delivery accuracy.
- Enhanced `MeshMessage` to consider channel-specific flood scopes when determining the effective outgoing flood scope.
- Added unit tests to verify the new flood scope resolution logic and ensure correct behavior across various scenarios.
2026-06-27 17:09:28 -07:00
agessaman 986fe67c01 feat(test_command): add RSSI placeholder support in response formatting
- Enhanced the `TestCommand` class to include the `rssi` value in the response format, allowing for more detailed output.
- Updated the example configuration to reflect the new placeholder usage for `rssi`.
- Added a unit test to verify that the `rssi` placeholder expands correctly in the response message.
2026-06-27 16:41:11 -07:00
agessaman 3b6ef66fd9 feat(connection): enhance command serialization and pacing for radio communication
- Introduced a new class `_SerializedCommands` to serialize host-to-radio commands, ensuring only one command is in-flight at a time to prevent USB-CDC buffer overruns and parser desynchronization.
- Added configuration options for `command_min_interval_ms` and `channel_fetch_interval_ms` to control pacing between commands and channel scans, improving firmware stability during reconnect sequences.
- Updated `ChannelManager` to utilize the new fetch interval during channel scans, and modified the reconnect delay to 10 seconds for gentler recovery from connection issues.
- Enhanced documentation in `config.ini.example` to reflect new settings and their purposes.
2026-06-22 18:58:08 -07:00
agessaman ea50ff274d feat(config): update public key prefix length configuration
- Added a new configuration option `prefix_bytes` to specify the length of public key prefixes for repeaters, enhancing mesh graph key management.
- Updated the example configuration to reflect the new default value of 3 bytes, replacing the previous default of 1 byte.
- Improved documentation within the config file to clarify the purpose and usage of the `prefix_bytes` setting.
2026-06-21 10:52:31 -07:00
agessaman f7ec71a114 feat(worldcup): enhance match state announcements with card and stoppage tracking
- Added configuration options for announcing red cards, yellow cards, and match stoppages in `config.ini.example`.
- Updated `ESPNClient` to parse and include red and yellow card details in match state data.
- Enhanced `WorldCupLiveService` to announce red and yellow cards, including formatted messages for players and stoppage events.
- Implemented unit tests to validate the new card and stoppage announcement functionalities, ensuring accurate reporting during matches.
2026-06-18 16:29:27 -07:00
agessaman 21676f0792 feat(worldcup): add support for announcing disallowed goals and enhance goal tracking
- Introduced a new configuration option `announce_disallowed` to allow notifications for goals overturned by VAR.
- Updated `WorldCupLiveService` to handle disallowed goals, including formatting for previously announced scorers.
- Enhanced `ESPNClient` to ensure accurate goal tracking and state management.
- Added unit tests to validate the new disallowed goal functionality and ensure correct behavior during matches.
2026-06-17 22:11:24 -07:00
agessaman 7537c76c63 feat(worldcup): add World Cup command and service configuration
- Introduced World Cup command and service settings in `config.ini.example`, `config.ini.minimal-example`, and `config.ini.quickstart` to enable FIFA World Cup score tracking and updates.
- Enhanced `espn_client.py` with methods to fetch tournament calendar and match states, supporting the new World Cup features.
- Updated `sports_command.py` to resolve World Cup nation names and integrate with the new command.
- Added translations for World Cup commands and messages in multiple languages, ensuring comprehensive support for users.
- Included mappings for World Cup nations in `sports_mappings.py` to facilitate accurate team identification.
2026-06-14 19:33:20 -07:00
rlwilliamson-dev e5666b7cce feat(rain): minute-level rain nowcast command + proactive incoming/ending push
Add a rain nowcast (Open-Meteo 15-minutely precipitation — worldwide, no API
key) as both an on-demand `rain`/`nowcast` command and an opt-in Weather_Service
push that announces rain starting and stopping at the bot's position.

- modules/commands/rain_command.py — command plus pure, unit-tested
  fetch/analyze/dedup/label helpers (also reused by the service)
- modules/service_plugins/weather_service.py — background poller mirroring the
  existing weather-alert poll pattern; ships disabled (opt-in)
- Location labels resolve to "City, ST" (US) / "City, Country" (non-US)
- 34 unit tests; rain_command added to the strict-mypy module list
2026-06-03 18:56:24 -05:00
Felix Moessbauer ae6c5fbd6a feat(darc_mowas_service): set scope based on alert region
As scopes now become usable in MeshCore, we want to limit the
distribution of the alerts to the regions they are issued for. For that,
we provide a configuration interface to define which region ids
("Regionalschluessel") are mapped to which MeshCore scopes. This reduces
the noise on the warning channels.
2026-05-19 17:26:29 +02:00
agessaman 0b56e86de9 feat(multibyte): add multibyte monitor feature with API endpoints
- Introduced a new configuration option `multibyte_monitor_enabled` in `config.ini.example` to control the visibility of the multibyte monitor page and API endpoints.
- Implemented the `/multibyte-rollout` and `/api/multibyte-rollout` routes in the web viewer, which are accessible only when the multibyte monitor feature is enabled.
- Updated documentation in `web-viewer.md` to reflect the new feature and its configuration.
- Added tests to ensure the multibyte routes are disabled by default and accessible when enabled.
2026-05-18 16:39:48 -07:00
agessaman cb54ca6a45 fix(config): clarify flood scope configuration and logging behavior
- Updated `config.ini.example` and `configuration.md` to enhance clarity on the `outgoing_flood_scope_override` and `flood_scopes` settings, detailing their behavior and interactions.
- Improved logging in `CommandManager` to provide better insights into scope resolution and potential issues during message sending.
- Added new methods in `MessageHandler` for improved RF data correlation, ensuring eligibility checks for flood scope matching.
- Enhanced unit tests to cover new behaviors and ensure robust handling of flood scope configurations.
2026-05-17 14:38:47 -07:00
agessaman 74b7de6f72 feat(flood_scope): enhance flood scope handling across services
- Introduced optional regional TC_FLOOD scope configuration for various services, allowing for more granular control over message routing.
- Updated the CommandManager to resolve and apply flood scopes from messages, configuration sections, and explicit parameters, ensuring correct message delivery.
- Enhanced service plugins to utilize the new flood scope functionality, including weather, earthquake, and webhook services.
- Added unit tests to verify the correct resolution and application of flood scopes in different scenarios, ensuring robust functionality.
2026-05-16 12:08:24 -07:00
agessaman 9d24e4824c feat(config, docs): add JWT configuration options for MQTT authentication
- Introduced `jwt_ttl_seconds` and `jwt_renewal_interval` settings in `config.ini.example` for global JWT management, allowing for better control over token expiration and renewal intervals.
- Updated documentation in `packet-capture.md` to clarify the usage of global and per-broker JWT settings, enhancing user understanding of authentication configurations.
- Refactored `PacketCaptureService` to incorporate new JWT settings, ensuring consistent handling of token lifetimes and renewal processes.
2026-05-15 20:46:05 -07:00