Commit Graph
100 Commits
Author SHA1 Message Date
agessaman 6ca305fa6a fix(region-map): resolve mesh.default_region onto the dispatcher default scope
Firmware's ``simple_repeater`` resolves a ``default_scope`` TransportKey at boot
from ``region_map.getDefaultRegion()`` and answers the ``REPLY_SCOPE_DEFAULT``
row with ``sendFloodScoped(default_scope, ...)``. The comment on that branch is
the whole point of it::

    // un-scoped would be dropped at hop 0 by repeaters running flood.max.unscoped=0

We had the config for it -- ``mesh.default_region``, which the web API already
auto-creates as an allow-flood region -- but nothing carried it to
``Dispatcher.default_flood_transport_key``, so it only ever reached adverts and
the neighbours publisher. Core's send-time resolver therefore found no default
and left the reply a plain flood.

The row is reached whenever the request's scope is *unknowable* rather than
un-scoped: a DIRECT request we hold no return path for, or a TRANSPORT_FLOOD
whose code matched no served region. Both are ordinary traffic, and on a mesh
running ``flood.max.unscoped = 0`` both replies died at hop 0 while firmware
would have delivered them.

``resolve_default_scope_key`` resolves through the built RegionMap rather than
hashing the configured name, for parity with ``getDefaultRegion()``, which can
only return a region the map holds: a ``$private`` default gets its stored key
material, and a default naming an unserved region resolves to nothing rather
than to a scope no local Region would match on the way back in. An unset default
and the ``*`` wildcard resolve to None, which is firmware's
``default_scope.isNull()`` row -- a plain flood.

Re-resolved from the three places that change the answer: boot, the
transport_keys change hook, and a runtime ``mesh.default_region`` edit. That last
one matters because the web API creates the region *before* writing the config,
so the storage hook has already run against the previous value.

Companion bridges need no separate wiring: a deferred reply from a bridge's
login server reaches the shared dispatcher unmarked, so it resolves here, which
is what firmware ``simple_room_server`` does with its own ``default_scope``.

The two end-to-end tests skip on a core that still resolves REPLY_SCOPE_DEFAULT
at RX and marks the reply final, since the assignment is correct but inert
there. They are gated on the behaviour rather than on a version, so they start
running by themselves once that core lands.
2026-09-07 22:27:50 +01:00
agessaman 0cecb17060 fix(engine): wait out a radio-link outage instead of spending the retry on it
A local transmit retried after a flat 1s backoff. When the failure was the
link itself -- a KISS modem that dropped off USB and is re-enumerating, which
takes about 2s to come back and re-handshake -- that retry landed inside the
outage and failed immediately, turning one modem hiccup into a failed message
even though the companion client was still waiting.

A transient failure with the link up keeps its 1s backoff outside _tx_lock. A
dropped link is now waited for instead, polled at 100ms and bounded by
delays.local_tx_link_wait_seconds: default 5s, non-finite values rejected and
everything capped at 30s, because the client's own command timeout is already
part-spent on the TX delay by the time we get here. Relayed packets never
wait -- a repeat held back that long is worse than one dropped.

Link state is read from the radio the send will actually use. Attribute
access on a FabricRadio passes through to its default endpoint, so a send
carrying preferred_tx_radio_id would otherwise be judged by the wrong radio,
suppressing a healthy one or waving through a dead one. A multi-radio fabric
with no explicit target picks its endpoint at send time, so nothing is
inspected and the send proceeds as before. Radios that track no link state
(a directly driven SX1262) answer nothing and keep their exact timing.
2026-09-07 22:27:50 +01:00
agessaman 2cf65abf62 fix(room): end a pushed post's text at an embedded NUL
pushPostToClient sizes the body with strlen(post.text) for the payload and
the expected ACK alike. The web API will store "a\0b", and pushing it whole
sends text the receiver cannot see -- it stops at the NUL -- while hashing
the pending ACK over a span the receiver never reproduces. The push would
then never be acknowledged and would retry into the failure backoff.

Same defect the core builder had; this is a second, hand-rolled builder.

Also proves MC-R4 end to end at last: a genuinely encrypted guest PLAIN
through a real registered room identity, watching the injector for an ACK
that must not appear. The seam was only covered in three separate halves
before -- core honours a veto, _may_ack computes one, a mocked constructor
receives one -- none of which would have caught the wiring being dropped.
2026-09-07 22:27:50 +01:00
agessaman 1e61b392b8 fix(text): withhold the ACK for anything a server would refuse
The core text handler answers by BaseChatMesh's rule -- plain and signed
text, never a CLI type -- and it runs before this helper's gates. So a
signed post, a non-admin command or a guest's post was acknowledged and
only then thrown away: the sender saw delivery confirmed for a message
that went nowhere.

_may_ack is firmware's rule for a server, handed to the core handler as
should_ack_fn at register_identity. simple_repeater builds an ACK only
under `if (flags == TXT_TYPE_PLAIN)`, inside a branch already gated on
isAdmin(); simple_room_server sets send_ack only on the non-guest PLAIN
path. Neither answers a CLI type, an unsupported type or a replayed
timestamp -- but both still answer a retry, because firmware suppresses
the work, not the reply. It reads no state it does not also leave alone.

It predicts eligibility, not the outcome: a post that clears the role and
replay checks and is then refused by openHop's own rate limit or a
database error is still ACKed. Those refusals have no firmware
counterpart, so closing that gap needs the store result fed back here.

Guarded by a signature check: passing should_ack_fn to a core that does
not take it raises TypeError out of register_identity, which would leave
the node with no text handler at all. pyproject.toml tracks
openhop_core@dev with no version floor, so that is an ordinary state --
and in it the ACK goes back to being sent, availability over the
invariant.
2026-09-07 22:27:50 +01:00
agessaman 0d50f5feec fix(text): dispatch on the text type and guard replays
Two things firmware does that openHop did not.

Dispatch by type, not by text. simple_room_server::onPeerDataRecv routes
CLI_DATA/CLI_COMMAND to the admin CLI and turns PLAIN into a post;
simple_repeater has no chat function, so it runs every accepted type
through handleCommand with no text test at all. openHop asked
_is_cli_command(message_text) for both, and so got both wrong: a PLAIN
"set ..." ran as a command instead of being posted, a command whose text
was not in the prefix list was published to the room, and an admin's
mistyped repeater command vanished silently instead of being answered.

Guard replays. Both servers open the branch with
`sender_timestamp >= client->last_timestamp`, treat equality as a retry
they accept but do not re-run, and drop anything older. openHop checked
neither, so replaying an admin command with the same timestamp and
different attempt bits re-executed it -- packet dedup does not catch that
because the bytes differ. Note ACL._is_replay is not reusable here: its
strict <= rejects the equal case this branch accepts.

Both read from packet.decrypted, which openhop_core now publishes. A core
too old to report them falls back to the previous text-prefix behaviour
and logs a warning rather than dropping every message.
2026-09-07 22:27:50 +01:00
agessaman e6d1a43d86 fix(room): a guest may read the room, not post to it
simple_room_server::onPeerDataRecv takes the
`(client->permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST` branch for a
PLAIN message and stores nothing. openHop called add_post for anyone who
authenticated, so knowing the guest password was enough to write to a room:
add_post caps length and rate-limits per client, but never looks at the role.

Also warns once per message when openhop_core does not publish txt_type,
so the open text-type gate is visible in the log rather than silent.
2026-09-07 22:27:50 +01:00
agessaman 369c6142b6 fix(text): gate the CLI on the text type, not the text
simple_repeater::onPeerDataRecv and simple_room_server::onPeerDataRecv
both open their TXT_MSG branch by rejecting any flags outside
{PLAIN, CLI_DATA, CLI_COMMAND}. openHop had no such gate: it decided what
a message was purely from whether the text looked like a command, so a
SIGNED_PLAIN -- a room post, whose 4-byte author prefix the core handler
has already stripped, leaving bare text -- ran as an admin CLI command
whenever it happened to start with a command prefix, and was stored as a
post on a room server that firmware would have dropped.

CLI_COMMAND (3) joined the accepted set when firmware split "a CLI
command" out of CLI_DATA (MeshCore 2c0ace25); CLI_DATA stays in it
because that is what every released client still sends.

Reads the type from packet.decrypted, which the core text handler now
publishes. A core too old to report it leaves the gate open rather than
dropping every message.
2026-09-07 22:27:50 +01:00
Adam Gessaman a2f883fd7d Merge pull request #452 from agessaman/ci/ruff-format-dev
ci: reformat two files ruff 0.15.14 would rewrite
2026-09-07 13:15:10 -07:00
agessaman 3efcd37f6f ci: reformat two files ruff 0.15.14 would rewrite
`pre-commit run --all-files` fails on `dev` at the `ruff-format` hook, so every
open pull request is red on a check that has nothing to do with its own diff.
Both files arrived with 72da7ee (pr-436) formatted by an earlier ruff.

Purely cosmetic, and exactly what `ruff format` produces:

- `sqlite_handler.py` — a generator expression and an f-string concatenation
  that both now fit inside the line limit unsplit.
- `test_companion_import_repeater_contacts.py` — the blank line ruff requires
  after a module docstring.
2026-09-07 13:05:19 -07:00
agessaman f91d2e9504 test(acl): drive the wire tests from core's conformance vectors
The end-to-end tests asserted reply[7] == PERM_ACL_ADMIN, which follows
the constant wherever it goes — the one thing these tests exist to stop.
They now assert the literal bytes from openhop_core's acl_conformance
table, so both repos are pinned to the same numbers rather than to each
other's vocabulary. Checked out against 2a55dd6 with these tests copied
in, all seven fail.

Also cover the fail-closed import guard, which was the only untested
path protecting against a mixed-version install, plus its happy path so
a guard that always fires cannot pass unnoticed.
2026-08-21 18:56:23 -07:00
agessaman 7a2fb9b2df fix(acl): fail closed when openhop_core is too old for the role numbering
acl.py kept its own copy of the role constants, so an installation could
pair the new numbering with a core that still derives is_admin from the
0x02 bit. That combination announces a room server's READ_WRITE clients
(role 2) as admins on the login reply's is_admin byte — silently, and
exactly where this change is meant to make the two bytes agree. The
dependency is a mutable openhop_core@dev, so nothing prevented it.

Import the roles and acl_is_admin/acl_role from openhop_core instead of
duplicating them, and raise an actionable ImportError when they are
absent: refusing to start beats misannouncing a client's privileges. The
drift test no longer skips on an old core, so CI reports the mismatch
rather than hiding it.
2026-08-21 12:00:58 -07:00
agessaman 437a7ee332 fix(api): declare read_write in the ACL client permissions enum
The ACL client list now reports the real role, so a room server's
read-write clients emit "read_write" — a value the OpenAPI schema did
not allow, which generated clients and validators reject.

Add it to the enum, document all four roles, and add a test asserting
every role_name() we can emit is declared in the schema.
2026-08-21 11:57:24 -07:00
agessaman 3023eaca89 fix(acl): align ACL role numbering with upstream MeshCore
OpenHop numbered the ACL roles GUEST=0x01, ADMIN=0x02, READ_WRITE=0x01.
Firmware ClientACL.h uses GUEST=0, READ_ONLY=1, READ_WRITE=2, ADMIN=3 in
the low two bits, so our admin byte decoded as READ_WRITE in every stock
MeshCore client (KiekR showed admin sessions as guest and hid the admin
controls), and GUEST/READ_WRITE were indistinguishable from each other.

Adopt the firmware values and split the guest password by server type,
as firmware does: a repeater's guest password grants GUEST (fetch base
telemetry, no settings changes) and a room server's grants READ_WRITE
(post and read messages). Blank-password read-only access stays GUEST,
matching simple_room_server's allow_read_only branch.

Also fix the mesh-CLI admin gate in text.py, which shadowed a local
PERM_ACL_ADMIN = 0x02 and tested the bit rather than the role — under
the corrected numbering that would have let READ_WRITE clients run admin
commands. The web ACL list now reports the real role name instead of
collapsing every non-admin to "guest".

Requires openhop_core with acl_is_admin() in the login reply
(openhop_core fix/login-perms); the two must land together.

Fixes #388
2026-08-21 11:17:15 -07:00
agessaman e1b480d279 feat(mqtt): enable neighbors configuration in meshat-se preset
Added a new `neighbors: true` setting to the `meshat-se.yaml` configuration file under the MQTT brokers section, allowing for neighbor discovery functionality to be enabled in this preset.
2026-07-30 13:03:31 -07:00
agessaman 1221734ca2 feat(discovery): preserve existing advert metadata during discovery result persistence
Enhanced the `persist_discovery_result` function to retain advert metadata such as `node_name`, `latitude`, and `longitude` from existing records when updating neighbor information. This prevents overwriting valuable metadata with `None` values during SQLite updates. Added a corresponding test to verify that existing metadata is preserved correctly during the discovery process.
2026-07-29 13:41:27 -07:00
agessaman 2b8be8cb7e feat(api): enhance neighbor scopes API with served scopes
Added a new `served` field to the neighbor scopes API response, which includes the node's own advertised scopes. This allows clients to identify which scopes they already share with neighbors without needing to re-derive the rules. Updated the OpenAPI specification to reflect this change and added a new private method to retrieve the served scopes. Additionally, rebuilt the UI assets to incorporate these changes.
2026-07-29 13:17:15 -07:00
agessaman 4585094a29 chore(web): rebuild bundled UI assets
Picks up the neighbours scopes column, panel and menu entry. The whole hashed
bundle turns over because the vite build empties its output directory, so this
reflects the UI repo's current state rather than only that change.
2026-07-29 09:58:54 -07:00
agessaman 9c417478f3 feat(neighbors): persist neighbour scopes and expose them over the API
Scope answers were built into the MQTT payload and then discarded, so the web UI
had nothing to show between cycles and no way to ask a single repeater. Adds a
store for them, a read endpoint, and a single-target query.

Migration 15 adds neighbor_scopes: one row per queried neighbour holding the last
answer (`scopes`, `responded_at`) alongside the last query's outcome
(`status`, `queried_at`). The two are kept apart deliberately -- a failed query
updates the outcome but leaves the answer in place, because the responder
rate-limits anonymous replies to 4 every 3 minutes and one timeout is weak
evidence that a neighbour's scopes changed. An empty answer is stored as a real
answer: it means the neighbour serves unscoped traffic only.

A row is written for any query the node attempted. `timeout` alone cannot carry
that: the sweep reports it both for a neighbour that was asked and stayed silent
and for one it never reached, which is what ScopeResult.transmitted separates.
`send_failed` is recorded too even though nothing reached the air -- it was
attempted and the duty cycle refused, and skipping it left a repeater that keeps
refusing reading as "never queried" however often it was asked.

Scope rows follow their neighbour out of the database, on all four paths that
delete adverts: the two explicit deletes, the 6-hourly retention cleanup, and a
purge of the adverts table. Without the last two the table grew without bound and
a purge left scope counts on screen for repeaters no longer listed.

GET /api/neighbor_scopes serves the table; it stays separate from the paginated
advert queries, which are read per contact type on every page load.

POST /api/query_neighbor_scopes asks one neighbour now. Unlike publish_neighbors
it holds the request open for the reply, since the response window is normally its
5 s floor; past 45 s it returns and leaves the query running so a late answer
still lands rather than throwing away spent airtime. It returns the stored view,
not the raw result, so a failed query cannot tell the client to forget scopes the
database still holds. Nothing is published -- the periodic cycle owns the topic.

A query and a cycle must not collide over the scope helper. A cycle holds it for
its whole run including the discovery window, so a query refuses while one is
active; a cycle defers while a query is in flight; and if the two still race, the
cycle abandons the pass on the short retry delay instead of publishing a table
with every scope missing or dying and re-spending its discovery broadcast. Queries
are tracked so shutdown cancels them rather than transmitting through teardown.

The endpoint needs json_in: it is not on globally for /api and cherrypy's Request
has no `json` attribute without it, so reading the body would have failed on every
real request. A test asserts the decorator rather than trusting a fabricated
cherrypy.request, which is how it went unnoticed.
2026-07-29 09:58:41 -07:00
agessaman 7f79b6797c chore(web): rebuild bundled UI assets
Built from openhop_RepeaterUI feat/mqtt-neighbors (5178ee8), which adds the
neighbour publishing controls and the Publish Now trigger on top of that repo's
dev branch. The chunk set is unchanged from the previous build (62 chunks, none
dropped); only content and hashes differ.
2026-07-28 22:03:52 -07:00
agessaman 4fa25893ad feat(mqtt): neighbours trigger endpoint, payload counters, persistent schedule
Follow-up work on the neighbours feature, plus one crash found while reviewing it.

The crash: `mqtt_brokers.neighbors` is a settings block, but the per-broker key of
the same name is a boolean and config.yaml.example documents both, so
`neighbors: true` under mqtt_brokers is an easy hand-edit to make. Every reader
called .get() on it directly, raising AttributeError inside
NeighborScopeHelper.refresh_config -- which is built during daemon init, so the
daemon would not start at all. A shared neighbors_config_block() accessor now
ignores a non-mapping (with one startup warning instead of per-tick noise) and
the API save rewrites it as a proper block.

Payload counters, mirroring firmware buildNeighborsMessage:

- total_neighbors and queried_neighbors. The latter cannot be derived from
  `status`: a neighbour the sweep never reached reports `timeout` exactly like one
  that was asked and stayed silent. ScopeResult therefore carries a `transmitted`
  flag, set only past the point where the injector confirms the request is on air.
- Firmware's third field, `truncated`, is deliberately not emitted -- it reports a
  fixed PSRAM JSON buffer overflowing, which openhop does not have. total_neighbors
  here consequently always equals the published row count.
- self.default_scope: the region this node stamps on outgoing floods, or `*` when
  unset. Read from live config so `region default <name>` applies without a
  restart, and `#`-stripped like the scopes string beside it. Firmware tracks this
  internally but does not publish it, so this field is openhop-only.

POST /api/publish_neighbors runs a cycle immediately, the HTTP twin of the
`discover.scopes` mesh CLI command. Authenticated by default along with the rest
of /api, and it schedules the cycle on the event loop rather than holding the
request open for the minutes a cycle takes.

Schedule persistence: _next_publish_at is monotonic and _last_publish_at was
in-memory only, so every restart read as "due" and spent a discovery broadcast
plus one serialized scope query per neighbour. A generic daemon_state table
(migration 14) now records the last publish and start() resumes from it, never
sooner than a five-minute grace window and never later than one interval. Two
details this had to get right:

- The schedule keys off the last *successful* publish. A failed publish also sets
  _last_publish_at and reschedules on the short retry delay; persisting that would
  silently turn the retry into a full interval.
- _tick's disabled branch no longer clears the schedule before the feature has
  been enabled in this process. At boot the MQTT connections may not be up, so
  enabled() can briefly be false, and clearing there would discard the restored
  schedule and re-run the sweep anyway -- the exact thing this prevents.
2026-07-28 22:03:33 -07:00
agessaman 2aafedae02 fix(discovery): persist discover.neighbors results with the storage actually wired in
MeshCLI._auto_add_discovery_result reached for storage_handler.record_advert and
returned early when it was absent. record_advert lives on StorageCollector, but
MeshCLI is constructed with the SQLiteHandler (main.py -> TextHelper ->
MeshCLI), which exposes only store_advert. The guard therefore always failed in
production: `discover.neighbors` discovered nodes and recorded none of them, so
`neighbors` listed whatever adverts happened to arrive rather than what
discovery found. Tests missed it because they injected a hand-rolled double that
did have record_advert.

Extract the shared persistence into discovery.persist_discovery_result, which
accepts either storage object its callers actually hold: record_advert when
present (store plus the MQTT/Glass advert publish), falling back to
store_advert. neighbors_publisher._enrich_discovery_result, which had its own
working copy against the StorageCollector, now calls it too, so the two paths
build an identical advert row from one implementation.

The regression test specs its mock off the real SQLiteHandler class rather than
hand-rolling the surface, so a double can no longer claim a method the wired-in
object does not have. It fails against the previous code with KeyError
'auto_added'.
2026-07-28 21:53:22 -07:00
agessaman 48919c4605 fix(mqtt): address adversarial review of the neighbours feature
- api_endpoints: the broker rebuild is a strict field whitelist, so a client
  that omits `neighbors` (every UI build predating this feature) reset every
  broker to false on any MQTT save, silently disabling the feature. Fall back
  to the stored per-broker value instead. An explicit false still turns it off.
- neighbor_scopes: `_query_one` cleared `_pending` in an `except Exception`,
  which does not catch CancelledError. A shutdown cancel during the injector
  await -- where a query spends most of its wall time -- left a dead pending
  query that kept consuming RESPONSE packets and hiding them from every
  companion bridge. One try/finally around the whole method.
- neighbor_scopes: refresh_config had no caller after construction despite
  promising live updates; it now re-reads before each sweep, so a live
  delays.direct_tx_delay_factor change resizes the response window.
- neighbor_scopes: truncate scopes at the first NUL as the responder's C-string
  writer intends, rather than only rstrip-ing the cipher's zero padding.
- api_endpoints: accept the documented max_sweep_seconds and
  duty_cycle_abort_seconds, reject unknown keys instead of reporting success on
  a typo, and reject non-bool `enabled` / fractional interval_hours rather than
  silently coercing them.
- mqtt_handler: check paho's rc before counting a neighbours publish as
  delivered; the payload is uncapped, so a size/queue rejection is realistic.
- neighbors_publisher: retry after 15 minutes when a cycle publishes nothing,
  instead of burning the full 24h interval.
- neighbors_publisher: track the manual `discover.scopes` cycle so shutdown can
  cancel it, and move the already-running check onto the event loop.

Tests: router-hook integration (a matching response is consumed, an unrelated
one still reaches companions), cancellation, sweep re-entry, malformed and
hostile response payloads, config validation, and the broker save round-trip.
All ten regression tests fail against the pre-fix code.
2026-07-28 21:53:22 -07:00
agessaman eaf28e28b7 feat(mqtt): periodic neighbours publication with serialized scope discovery
Python port of the firmware's WITH_MQTT_NEIGHBORS feature (MeshCore PR #35 plus
the aba571ed pacing rework). Each cycle refreshes the zero-hop neighbour table
with a node-discovery broadcast, asks every neighbour for its region scopes, and
publishes the assembled table to the MQTT neighbors topic.

- NeighborScopeHelper: client side of the anon-regions request (the server side
  already existed in openhop_core). Queries are strictly serialized -- firing
  them as a burst makes the responses collide, which is what the firmware fix
  addressed. router.inject_packet resolves at the firmware's logTx/logTxFail
  boundary, so the response deadline is armed only once the request is on air
  and the firmware's QUEUED/PENDING state machine collapses into an await.
  The response window is sized from radio parameters, mirroring
  neighborDiscoverQueryTimeoutMs().
- NeighborsPublisher: owns the schedule, the immutable per-cycle snapshot, and
  the payload. The snapshot merges live discovery responses over the stored
  table because get_neighbors() serves a 60s cache that store_advert() does not
  invalidate.
- packet_router: offer PAYLOAD_TYPE_RESPONSE to the scope helper before the
  companion fan-out; it consumes only on an authenticated tag match.
- Per-broker `neighbors: true` opt-in (default off) plus a
  mqtt_brokers.neighbors.enabled master switch; interval is 12-336h as in
  firmware, rejected rather than clamped.
- `discover.scopes` mesh CLI command triggers one cycle immediately.

The whole zero-hop table is published including neighbours that did not answer
(timeout / send_failed), matching the firmware payload. No 10 KB cap: that
existed for a fixed PSRAM buffer.
2026-07-28 21:53:22 -07:00
agessaman e8385dac18 fix(repeater): implement origin exclusion for injected packets
Enhance the PacketRouter to ensure that a companion does not receive its own transmitted packets. The `_companion_bridges_for_packet` method now tags packets with the originating companion's hash, preventing redelivery to the same bridge during fan-out. This change applies to various packet types, including advertisements and group messages. Additionally, tests have been added to verify that injected packets are correctly filtered, ensuring that the originating bridge does not process its own transmissions while still allowing other bridges to receive them.
2026-07-26 13:04:17 -07:00
agessaman 8310ccdee6 fix(repeater): make SIGTERM actually shut the daemon down
SIGTERM did not stop the daemon. Observed: the process stayed alive 18+
minutes with all three companion listen sockets still bound and the serial
port still held, so a restart could not reopen the radio. Two defects
compounded.

Cleanup never ran. The signal handler cancelled run(), whose finally then
awaited _shutdown() from inside an already-cancelled task -- so the first
await raised CancelledError, run() returned, and asyncio.run() tore the loop
down before a single shutdown step executed. Not one step logged. Running
cleanup in a sibling task instead does not fix it either: run() returns as
soon as the dispatcher stops and asyncio.run() cancels every leftover task
on the way out, which showed up as 'frame server :5050' being cancelled
mid-stop. The handler now unwinds run() cooperatively by stopping the
dispatcher, so run_forever() returns on its own and cleanup runs in a task
that is not being cancelled. Cancelling is kept only as a fallback for a
failure before the dispatcher exists.

Nothing bounded the steps. Frame servers, bridges, router and Glass had no
timeout, so one stuck step stranded every step after it -- including
releasing the radio. Each step is now bounded by SHUTDOWN_STEP_TIMEOUT_S and
logged by name, so a hang is both survivable and diagnosable, and the sync
steps (HTTP stop, sensor manager, GPS, radio cleanup) run off-loop so a
blocking close cannot stall the sequence. The dispatcher is stopped first so
RX ends before its radio is released.

Even with cleanup fixed, a single non-daemon thread that never returns hangs
SIGTERM forever: interpreter finalization joins them with no timeout, which
is where the original 18-minute hang sat (main thread parked in
Py_FinalizeEx -> wait_for_thread_shutdown). Report any that are still alive
by name so the offender can be fixed at the source, excluding asyncio's own
executor workers since asyncio.run() joins those under its own timeout. Then
arm a daemon watchdog that forces the process down SHUTDOWN_EXIT_GRACE_S
after cleanup finishes, so a future stray thread costs a delayed exit rather
than a stuck service.

Verified on hardware: SIGTERM and SIGINT both exit in 1s with zero warnings,
all ports and the serial device released, and MQTT publishing its offline
status before disconnecting.
2026-07-24 15:29:40 -07:00
agessaman 05dae761e7 fix(repeater): feed each bridge every pre-dedup flood copy
A bridge's return-path teacher can only pick the best-received route if it
sees every copy of a flood reply. It does not: the router hands a bridge
only the first copy, later ones being dropped by the engine's seen-table,
and the pre-dedup firehose lives on the dispatcher, which the bridge does
not own. So the teacher always taught from the first-arrived route, which on
a live mesh is routinely the worst one -- observed here, four copies of one
login reply landed over ~1.8s and the teach went out 0.4s in, embedding the
marginal first route.

Subscribe each bridge's note_flood_copy to the dispatcher's raw packet
subscribers at both bridge-creation sites, next to the existing region_map
handoff.
2026-07-24 15:07:12 -07:00
agessaman 3d89174038 fix(repeater): report all boot-time config errors without a stack trace
Startup config mistakes were handled inconsistently: an identity collision
exited cleanly, but a missing or invalid config file (FileNotFoundError /
RuntimeError from load_config) and a missing identity key (RuntimeError) dumped
a full traceback -- and load_config ran outside main()'s try, so those errors
escaped the fatal handler entirely.

Add repeater/exceptions.py with ConfigurationError(RuntimeError) and re-parent
IdentityConfigurationError onto it (it stays a RuntimeError, so existing
except-sites are unaffected). Raise ConfigurationError for the missing/invalid
config file, the config-load failure, and the missing identity key. Move
load_config and daemon construction inside main()'s try and catch
ConfigurationError there, logging just the message and exiting 1; unexpected
failures still log with a traceback.
2026-07-24 08:50:34 -07:00
agessaman 6e5d02999b fix(repeater): report identity config errors without a stack trace
A configured-identity collision (IdentityConfigurationError from the startup
preflight) is an actionable config problem, not a crash, but the top-level
fatal handler logged every exception with exc_info=True, burying the message
under a full traceback. Catch IdentityConfigurationError in main() and log just
the message before exiting 1; unexpected errors still get the traceback.
2026-07-24 08:33:36 -07:00
agessaman 95555e0c12 fix(repeater): relax identity prefix guard to same-namespace collisions
Local identities occupy two routing/persistence namespaces: companions
(companion_bridges[hash] plus the companion_* tables keyed by the hash byte)
and server-side identities (the repeater and every room server, which share
the login/text/protocol helper handlers[hash] slot and the room_* tables). A
one-byte prefix collision is only unrepresentable when both identities share a
namespace; a companion and a server-side identity live in physically separate
stores, and the packet router (_consume_via_local_candidates) already offers a
colliding packet to both and lets HMAC pick the owner.

Key IdentityManager state by (hash_byte, namespace) instead of the bare hash so
the guard rejects only same-namespace collisions. This keeps blocking the pairs
that actually break -- companion<->companion (bridge overwrite plus
companion_prefs PRIMARY KEY corruption) and server<->server, i.e.
repeater<->room-server and room-server<->room-server (handlers[hash] overwrite
plus room_* corruption) -- while allowing a companion to share a prefix with the
repeater or a room server, which was previously rejected despite being only a
cosmetic label clash.
2026-07-24 08:24:23 -07:00
agessaman 72e874f838 fix(regions): scope flood replies to the request's region
Build a core RegionMap from the node's served regions and wire it into
the dispatcher and every companion bridge, so a flood reply is re-scoped
to the region its request arrived under (or left plain for a wildcard /
direct request) -- matching firmware simple_repeater::sendFloodReply.
Previously replies went out plain, so a reply to a request in region B
was dropped by B-only repeaters.

The map is built once from the node-wide transport_keys table (each named
region -> RegionEntry, flags=REGION_DENY_FLOOD for deny-flood regions; the
'*' wildcard is deliberately not an entry so plain floods reply plain). A
single shared instance reaches the dispatcher and all bridges. Public
regions rely on name-hashing for their key; a stored key is carried only
when it is genuinely custom material the name would not reproduce, keeping
reply-matching aligned with the forwarding transport-code check.

Region edits at runtime (CLI, web API, Glass sync) all funnel through the
transport_keys CRUD methods, which now fire a post-commit change callback;
the daemon rebuilds the map and reassigns a fresh instance to the
dispatcher and every live bridge (atomic rebind, safe against an in-flight
find_match on the RX thread).

Requires openhop_core with Dispatcher.region_map / CompanionBridge.region_map.
2026-07-24 07:52:16 -07:00
agessaman 8fcd7902c2 docs(cli): state the zero semantics of flood.max in the set help
OpenHop treats max_flood_hops 0 as unlimited while firmware's flood.max 0
forwards nothing — the inverse. Until that edge is decided alongside the
policy-engine flood caps, say so in the help so a firmware-habituated
admin does not set 0 expecting to disable forwarding (that is what
'set repeat off' is for) and silently get unlimited flooding instead.
2026-07-19 07:40:08 -07:00
agessaman 0a65e0566b fix(router): mark companion delivery only after a bridge receives the packet
The PATH and protocol-response paths recorded the companion dedupe
unconditionally after the bridge fan-out, so a delivery where every
bridge raised was still suppressed for the full dedupe TTL — the client
lost the packet even though later mesh copies arrived. Have the fan-out
report delivery (at least one bridge completed without raising) next to
authentication, and mark the dedupe only on delivery, which is the
behaviour the marking helper's contract already documented. One healthy
bridge still counts as delivered, so duplicate suppression of repeated
mesh copies is unchanged.
2026-07-19 07:12:24 -07:00
agessaman bf72735233 fix(router): keep offering local candidates when a companion bridge raises
_consume_via_local_candidates awaited the targeted companion bridge with
no exception guard, so a raising bridge aborted the whole candidate loop:
the hash-colliding room-server or repeater identity registered at the
same one-byte dest hash was never offered the packet, and an unclaimed
packet never fell through to the forwarding engine. Wrap the bridge call
with the same log-and-continue handling the fan-out path already uses.
2026-07-19 07:12:10 -07:00
agessaman 1bbd2156a1 fix(trace): gate intermediate-hop trace relay on forward mode
Firmware only relays a TRACE at an intermediate hop when
allowPacketForward passes, so disabling forwarding stops trace relay.
The trace helper forwarded via packet injection, which is gated only by
the local-TX check — a repeater in monitor mode kept repeating traces
while reporting repeat off. Consult the repeater mode before relaying;
locally originated pings are injected directly and keep working in
monitor mode, and ping-response matching still runs before the relay
decision.
2026-07-19 07:11:49 -07:00
agessaman f19421ee6c fix(cli): clamp delay factors to the firmware ranges
Firmware rejects rxdelay outside 0-20 and txdelay/direct.txdelay outside
0-2.0; the CLI only rejected negatives, so a remote admin could set
delay factors far beyond what any firmware node would accept. Apply the
firmware ranges with the firmware error strings and state the ranges in
the help text. Existing configs with out-of-range values are untouched —
only new CLI sets are gated. Also adds an end-to-end regression test
running every set command against the real ConfigManager on a temp
config file.
2026-07-19 07:11:24 -07:00
agessaman 6db8ac97f5 fix(cli): point the flood advert interval at the engine timer key
set flood.advert.interval wrote flood_advert_interval_hours, a key
nothing reads, and get reported it with a default of 24 — the engine's
flood-advert timer (and the web API) consume send_advert_interval_hours
with a default of 10. Read and write the consumed key so the remote
command actually reschedules the timer; the orphan key is left in place
and ignored.
2026-07-19 07:10:37 -07:00
agessaman 101681fad4 fix(cli): accept radio parameters in MHz and stage them until restart
set freq and set radio stored the CLI's MHz/kHz inputs directly into
radio.frequency and radio.bandwidth, which the rest of the stack treats
as Hz — a freq change tuned the radio to a few hundred hertz. Convert to
Hz on write and validate set radio with the firmware gate (freq 150-2500,
bw 7-500, sf 5-12, cr 5-8, same error string; set freq stays unvalidated
like firmware). Radio changes are now saved without a live apply so the
reply's restart-to-apply contract is real: a live retune would cut off
the remote admin mid-session, and the all-or-nothing live radio path
would also have dragged staged frequency changes along with a tx tweak.
2026-07-19 07:10:20 -07:00
agessaman eae42d9e19 fix(cli): store security settings where login authentication reads them
The password, guest.password, and allow.read.only commands wrote a
top-level security section with a stale key name, while LoginHelper
authenticates from repeater.security.admin_password/.guest_password/
.allow_read_only — so remote password changes never took effect. Point
the set and get commands at the real subtree, and push repeater.security
onto the live repeater ACL during a repeater-section live update: the
ACL captures its passwords at registration, so without the refresh a
saved change would still only apply after a restart. Room-server ACLs
keep their per-identity settings passwords.
2026-07-19 07:09:42 -07:00
agessaman eaab0e5dca fix(cli): honor the boolean config save contract in set commands
ConfigManager.save_to_file returns a bare bool, but every mesh CLI set
command (and the password command) still tuple-unpacked it, so each one
wrote the YAML and then raised, skipping the live update and returning an
error to the remote admin. Route every save through one helper that
checks the bool, reports save failures honestly, and only live-applies
after a successful write. The test fixture mocked the stale tuple shape,
which is why the suite stayed green while the CLI was broken on the real
manager.
2026-07-19 07:08:44 -07:00
agessaman 0ed92013f9 refactor(frame_server): enhance message persistence handling in CompanionFrameServer
Updated the _persist_companion_message method to accept a queue_entry parameter for more precise removal of messages from the bridge queue. Introduced a new _remove_queue_entry method to ensure messages are removed by identity, preventing potential message loss during concurrent operations. Adjusted related tests to reflect these changes and verify correct behavior.
2026-07-18 19:37:03 -07:00
agessaman 2432332f87 refactor(airtime): introduce refresh_radio_params method for dynamic modulation updates
Added a new method to the AirtimeManager class to refresh modulation parameters without clearing transmission history. Updated ConfigManager to call this method after applying live radio configurations. Enhanced tests to verify that modulation updates occur correctly during live updates.
2026-07-17 17:38:11 -07:00
agessaman b11d4972ff test(repeater): add regression tests for client-repeat forwarding behavior
Introduced tests to verify that the repeater's bridge does not enable client-repeat forwarding in Core's Dispatcher. The tests confirm that the repeater maintains the expected inert behavior regarding client-repeat capabilities and preferences.
2026-07-17 09:23:49 -07:00
agessaman 2139e41fc5 fix(protocol): answer telemetry requests with cayenne lpp sensor data 2026-07-16 23:37:37 -07:00
agessaman 2cfeef588d fix(room_server): stop advancing the author sync watermark on post 2026-07-16 23:22:02 -07:00
agessaman 90eb3cae07 fix(acl): keep the session replay watermark monotonic 2026-07-16 22:42:34 -07:00
agessaman 1687c299d1 refactor(daemon): drop the neutralized own-packet stub 2026-07-16 22:39:15 -07:00
Adam Gessaman b664ab5959 Merge branch 'openhop-dev:fix/all-the-things' into fix/all-the-things 2026-07-16 22:29:24 -07:00
agessaman 377b054ab4 fix(room_server): push the stored post timestamp with a random attempt nonce
The pushed frame serialized the delivery wall-clock time instead of the
post's own stored timestamp, and left the flag byte's low 2 attempt bits
always zero. Firmware sends the stored post timestamp and ORs in a fresh
random attempt so a retried post produces a different packet hash and
ACK; with a fixed timestamp two retries within the same second would be
byte-identical and dropped as mesh duplicates. The serialized timestamp
now also equals the push_post_timestamp sync watermark, matching the
firmware invariant.
2026-07-16 20:37:30 -07:00
agessaman ef234f143b fix(room_server): hold the global transmit lock across the whole send
GlobalRateLimiter.acquire() enforced the inter-message gap inside an
async-with that released the lock before returning, so the caller's
radio transmission ran with no lock held and every room's sync loop
could push concurrently. acquire() now returns with the lock held
(released via release() in a finally, including on cancellation during
the gap wait), uses a monotonic clock so wall-clock jumps cannot skip
or stretch the gap, and push_post_to_client acquires it only around the
sync-state write plus the blocking send, so the ACK-timeout clock
starts when the transmission actually begins. Regression tests cover
two rooms sharing one limiter (no concurrent sends, gap enforced) and
lock release when the send raises.
2026-07-16 20:28:13 -07:00
agessaman 5a1822e642 test(engine): stop building reserved-version packets in fixtures
Firmware's Dispatcher::tryParsePacket drops any payload version above 0
at parse, and core's Packet.read_from now enforces the same rule, so a
version-1 routed ACK can never reach the relay; the wire vector
exercising that unreachable input is removed (header regeneration is
still covered by the remaining vectors, including transport-direct).
The advert fixture also OR'd the version-1 bit into its header; it now
builds the version-0 header that PacketBuilder.create_advert emits.
2026-07-16 20:28:04 -07:00
agessaman 1a4df1a151 fix(room_server): enforce firmware post text budget in bytes
The room post limit was 160 Python characters; firmware caps post text
at MAX_POST_TEXT_LEN = 160-9 = 151 bytes (the 160-byte encrypted text
budget minus the timestamp/flags/author-prefix header). Store posts
truncated to 151 UTF-8 bytes at a codepoint boundary, and clamp again
when pushing so previously stored oversized posts cannot produce
oversized frames.
2026-07-16 14:28:55 -07:00
agessaman 5d390ef694 Merge remote-tracking branch 'origin/fix/all-the-things' into fix/all-the-things
Keep PacketHashingUtils for ACK relay hashing alongside flood_rx_metrics
from the metrics refactor.
2026-07-16 12:20:06 -07:00
agessaman d886af0991 fix(engine): relay routed ACKs through their MeshCore branch
An ACK at an intermediate direct hop is now regenerated as a plain
DIRECT ACK (transport codes dropped, version bits cleared) and relayed
with zero retransmit delay, matching routeDirectRecvAcks, instead of
taking the generic delayed direct forward. The repeater.multi_acks
preference (already exposed by the mesh CLI) is now consumed by the
engine: when enabled, both ACK relay sites precede the plain ACK with
a MULTIPART-wrapped redundancy copy spaced by the direct retransmit
delay + 300 ms. The multipart relay seen-key now keeps the MULTIPART
payload type over the unwrapped payload, matching firmware hasSeen so
a redundancy pair survives each hop.
2026-07-16 10:48:52 -07:00
Adam Gessaman 40f0891785 Merge branch 'openhop-dev:fix/all-the-things' into fix/all-the-things 2026-07-16 10:22:25 -07:00
agessaman b69e1c89ee test(web): pin /api/identities payload with the repeater identity
The daemon registers its own default identity so companion and
room-server hash collisions against the repeater are caught, which
adds a repeater:repeater entry to the endpoint's raw registered list
and counts it in total_registered. The web UI only renders the
per-entry registered boolean on configured room servers and
companions (matched by prefixed name), so it is unaffected; pin the
payload shape for external API consumers, and pin that the bare name
"repeater" is reserved — a room server configured with that name is
rejected by the collision rules and reported unregistered.
2026-07-16 08:48:57 -07:00
agessaman 5a83fce84c refactor(router): centralize bridge fan-out and type drop reasons
Replace the eight hand-rolled companion-bridge delivery loops in the
packet router with a single _fan_out_to_bridges helper that reads
HandlerResult.authenticated directly, so a broken handler contract
surfaces instead of being hidden by getattr hedges.

Introduce a DropReason str-Enum in the engine for the seventeen fixed
drop reasons (with __str__ returning the value, since str() of a
str-Enum changed on Python 3.11+) and derive the router's
expected-drop check from it, retiring the string-prefix tuple.
Detailed variants keep their suffixes embedded in the reason string.

Split the companion-delivery dedupe cache into a pure check and an
explicit mark so PATH and protocol responses record delivery only
after the fan-out runs — a copy where every bridge raised is retried
on the next copy instead of being suppressed for the full TTL.
2026-07-16 08:48:34 -07:00
agessaman a620433312 Merge upstream/fix/all-the-things into fix/all-the-things
Keep both migration 12 (companion message signal/channel data) and
migration 13 (packet upstream hash for neighbour links), and retain
ACK/MULTIPART plus TRACE imports in engine tests.
2026-07-15 22:51:15 -07:00
agessaman 7c2e121d08 refactor(daemon): resolve companion max TX power via the core resolver
The daemon's max-TX-power lookup duplicated the core fallback chain and
ended in a radio_type string match to know the SX1262's 22 dBm limit --
a driver constant that every new backend name would silently lose. The
SX1262 driver class now declares max_tx_power_dbm itself (the CH341
variant instantiates the same class, only the transport differs), so
the lookup collapses to the shared resolver over the radio object and
the validated deployment settings.
2026-07-15 22:27:25 -07:00
agessaman f91a30afa1 docs(engine): state TX delay factor semantics at startup
The TX delay factors changed from absolute seconds to firmware-matching
airtime multipliers (random delay in [0, 5 * airtime * factor], per
MeshCore getRetransmitDelay), but deployed configs tuned under the old
seconds interpretation get no signal on upgrade. Log the interpretation
and both factors at startup, and fix the stale wording in
config.yaml.example and the web API docstring. Defaults are unchanged
(flood 1.0, direct 0.5; firmware ships 0.5/0.3).
2026-07-15 22:02:18 -07:00
agessaman 8a776a122e fix(cli): point txdelay settings at the delays config section
The mesh CLI's get/set txdelay and direct.txdelay read and wrote
repeater.tx_delay_factor / repeater.direct_tx_delay_factor, but the
engine and web API use the delays section, so the CLI knobs were
silently dead: set appeared to succeed while changing nothing, and get
echoed the unused value back. Same defect and same fix as the recent
rxdelay repoint.
2026-07-15 22:02:18 -07:00
agessaman 2d6b1131ac fix(storage): evict offline-queue rows set-based in insertion order
The companion queue's capacity path ran a Python loop of single-row
SELECT+DELETE pairs ordered by created_at. Wall-clock ordering meant a
backwards clock step (NTP correction) could make the just-inserted
channel row sort as oldest and wrongly reject the incoming message while
older channel rows remained evictable.

Replace the loop with one set-based DELETE ordered by id (AUTOINCREMENT,
i.e. insertion order, immune to clock steps), with an evictable-count
pre-check preserving the all-or-nothing rejection rule: never displace a
direct message, never evict the incoming row to make room for itself,
roll back the insert entirely when channel rows cannot make room. The
queue load and pop queries move to id ordering for the same reason.
2026-07-15 22:02:18 -07:00
agessaman 3e2231f2bc fix(repeater): wire the flood reception delay base into the dispatcher
The delays.rx_delay_base config value was stored and reported but never
applied. Now that the core dispatcher implements MeshCore's flood
reception-quality delay, feed it through:

- set dispatcher.rx_delay_base from delays.rx_delay_base at startup and
  re-apply it on live config updates of the delays section (web API
  saves already pass that section)
- point the mesh CLI get/set rxdelay at the delays section; it read and
  wrote repeater.rx_delay_base, which nothing consumes, so the CLI knob
  was silently dead
- delegate RepeaterHandler.calculate_packet_score to the shared core
  packet_score (same firmware packetScoreInt formula, one impl)

Suppression works through the existing engine seen-cache because it
dedupes at process time, after the dispatcher hold. Default remains 0
(delay disabled), matching firmware.
2026-07-15 21:22:32 -07:00
agessaman b9579b6c15 fix(airtime): delegate airtime math to the shared core estimator
AirtimeManager.calculate_airtime now uses openhop_core's
calculate_lora_airtime_ms, which matches RadioLib's getTimeOnAir. This
adopts the radio driver's symbol-time auto-LDRO rule in place of the
SF/BW shorthand, which diverged from hardware at 16.384 ms symbol
settings such as SF12 @ 250 kHz and SF10 @ 62.5 kHz. Test oracles
follow the same rule, with literal RadioLib-derived regression values
for the divergent settings.
2026-07-15 20:35:07 -07:00
agessaman 034cd6f566 fix(companion): persist queued message signal and channel data 2026-07-15 17:06:00 -07:00
agessaman a208fc3763 fix(repeater): relay multipart ACKs as regenerated direct ACKs
Match MeshCore's forwardMultipartDirect: a multipart packet is never
relayed as an ordinary directed packet. At an intermediate direct hop a
multipart ACK is re-emitted as a plain DIRECT ACK with the wrapper byte
stripped and this node removed from the path, deduped on the regenerated
ACK form and spaced by (remaining + 1) * 300ms. Flood routing, final
hops, and non-ACK embedded types are dropped rather than forwarded.

Coverage includes literal firmware-format wire vectors for one- and
two-byte hash widths and transport-direct input, end-to-end receive-to-
transmit relaying, and the drop cases.
2026-07-15 16:29:20 -07:00
Adam Gessaman 2646e11f8e Merge pull request #2 from agessaman/codex/local-identity-preflight-056
Consolidate identity collision validation in IdentityManager
2026-07-15 00:36:56 -07:00
agessaman 9a2559a253 refactor(companion): register persistence hooks via message events
Use openhop_core's new single-argument event registrations for the
frame server's SQLite persistence callbacks instead of the deprecated
positional on_*_received forms.
2026-07-15 00:31:32 -07:00
agessaman c22e4df37d refactor(repeater): consolidate identity collision validation
The startup preflight re-implemented the identity collision rules in
main.py, reconstructing IdentityManager state by string-parsing its
'type:name' labels, and every configured identity was parsed and
constructed twice (once for preflight, once for loading), duplicating
config warnings on each start.

Move batch validation into IdentityManager.validate_specs(), which
checks a batch of IdentitySpec entries against registered identities
and against each other without mutating state, and relocate
IdentityConfigurationError next to it. The daemon now parses room
server and companion configs once during preflight and the identity
loaders reuse the cached specs.
2026-07-15 00:12:42 -07:00
agessaman 697056aa93 fix(airtime): normalize legacy coding-rate indices
AirtimeManager fed the configured coding rate straight into the Semtech
symbol formula, so a config using the legacy index form (1..4) would
multiply payload symbols by the raw index instead of the 4/5..4/8
denominator and undercount airtime. Normalize through openhop_core's
shared coding_rate_denominator() before calculating.
2026-07-14 23:57:17 -07:00
agessaman 1b5cc71ed5 fix(repeater): validate local identity collisions 2026-07-14 22:54:27 -07:00
agessaman 74528af3ae fix(companion): expose repeater radio state 2026-07-14 21:44:27 -07:00
agessaman b5a327b925 fix(router): defer direct payload handling 2026-07-14 21:08:42 -07:00
agessaman 612a4c3f0e fix(companion): route group and raw data like MeshCore 2026-07-14 20:40:13 -07:00
agessaman 06c0f5268d fix(companion): persist messages before client connection 2026-07-14 18:35:05 -07:00
agessaman 164539597c fix(companion): keep restarted queue rows single-owner 2026-07-14 17:55:52 -07:00
agessaman 79cba76b4d fix(companion): protect direct offline messages 2026-07-14 16:59:34 -07:00
agessaman 1fe3fb1779 fix(storage): retain signed advert packets 2026-07-14 15:51:40 -07:00
agessaman 7621074d05 fix(router): accept maximum direct paths 2026-07-14 15:22:20 -07:00
agessaman 059065ce9c fix(router): never forward control packets to the engine
MeshCore never relays control packets: the direct high-bit discovery
subset is explicitly released and any other control payload hits the
switch default that does not flood-route unknown types. Mark control
packets do-not-retransmit unconditionally instead of only when discovery
is enabled, so a repeater with discovery disabled no longer forwards
control/discovery traffic.
2026-07-13 20:40:22 -07:00
agessaman 62ad7424c2 fix(router): consume PATH and RESPONSE only after MAC authentication
Extend the authenticated-ownership model to the PATH and RESPONSE
routing branches so a packet is consumed (do-not-retransmit) only when
a local identity MAC-verifies it. Prefix-only collisions and forged
traffic stay eligible for the forwarding engine.

- PathHelper marks do-not-retransmit and reports authenticated only
  after a successful MAC decrypt with a valid path envelope; invalid or
  truncated envelopes remain forwardable.
- PacketRouter aggregates authenticated results across the path helper
  and companion bridges for PATH and RESPONSE, skipping the engine only
  on authenticated ownership while preserving empty-path DIRECT release
  hygiene.
2026-07-13 19:54:59 -07:00
agessaman 8875177088 fix(router): consume packets only on authenticated local handling (#353)
Route login, text, and protocol-request packets through a single
_consume_via_local_candidates fan-out that offers each one to every local
candidate sharing the one-byte destination hash — the companion bridge and the
room-server/repeater identity — and consumes it only when one MAC-verifies it.
A prefix collision with a remote node (or a forged packet) is left for the
forwarding engine instead of being swallowed.

- gate processed_by_injection on the core HandlerResult.authenticated
- protocol-request now forwards on collision instead of always marking handled
- add real-crypto integration tests (text, room-server login, protocol-request)
  and router fan-out tests covering companion + room-server collisions
2026-07-13 14:46:56 -07:00
agessaman e22514882f perf: force timestamp range scan for windowed packet-stats GROUP BYs
The GROUP BY type and GROUP BY drop_reason sub-queries in
get_packet_stats (and get_packet_type_stats) filter by a time window but
let the planner pick idx_packets_type / idx_packets_transmitted to get
grouping for free. It then heap-checks the timestamp filter across the
entire table, turning a bounded window into a full scan: on a 1.5M-row
packets table the 24h stats load spent ~4.8s (type) + ~2.3s
(drop_reason) instead of ~0.1s each.

Pin these to idx_packets_timestamp with INDEXED BY so they range-scan the
window (~50k rows) and group via a small temp b-tree. Verified on the
live DB: 4.80s -> 0.10s and 2.32s -> 0.10s. Unlike a covering index this
adds no write-path cost on the packet-insert hot path.
2026-07-08 10:13:28 +01:00
agessaman 676e2cea30 perf: add covering index for airtime chart queries
The airtime/utilization chart queries (get_airtime_data and
get_airtime_buckets) range-scan and order packets by timestamp,
selecting only timestamp/length/payload_length/transmitted. On a large
packets table this forced a full scan of the row heap, saturating slow
storage (e.g. a Pi SD card): each dashboard poll took longer than the
client timeout, aborted polls stacked, and sustained I/O starved the
transmit queue.

Add a covering index on packets(timestamp, length, payload_length,
transmitted) so these queries run index-only, dropping the read from the
full row heap to just the index range. Verified via EXPLAIN QUERY PLAN
(COVERING INDEX idx_packets_airtime). Additive and idempotent.
2026-07-08 10:13:28 +01:00
agessaman d8bc448f82 fix: update dependencies for platform-specific installation of openhop_core 2026-07-08 10:13:28 +01:00
agessaman 4a055be8df fix(router): try both companion and server on colliding dest hash
On-air dest hashes are one byte, so a companion and a room-server (or
repeater) identity can share one. The login and text dispatch used
`if dest_hash in companion_bridges: ... elif login_helper/text_helper`,
so a companion silently shadowed a same-hash room server: its login
never reached the room-server handler and failed with Invalid HMAC
(e.g. Lake Stevens pubkey f55d.. / hash 0xf5 colliding with a companion).

Offer the packet to both handlers when both are registered at the hash;
decryption disambiguates — the key owner replies, the other fails HMAC
and no-ops. Non-colliding paths are unchanged. Applied to both the
LoginServerHandler and TextMessageHandler branches.

Adds regression tests covering collision (both invoked), companion-only
(server helper skipped), and server-without-companion for each path.
2026-07-07 14:51:59 -07:00
agessaman 954150b2d8 merge: reconcile companion cleanup with fix-general-tidy
Merge the maintainer's fix-general-tidy branch (neighbor discovery,
keygen, API endpoints, web-asset rebuild, HTTP server config/control
commands, and an independent #286 room-server push/ACK/guest fix) into
the companion cleanup branch.

Both branches fixed #286 in parallel with byte-identical push-ACK CRC
logic. In the six overlapping files the maintainer's implementation is
kept (ACL replay-detection/session helpers, encoded path-len with legacy
fallback, expected_crc/ack_timeout_s injector API, dispatcher ACK
helpers); this branch's unique companion work is preserved on top
(sender_prefix persistence + migration, boot-state hardening /
CompanionStateLoadError, MessageQueue.max_size, older-core fallbacks).

Conflict resolution took the maintainer's side across the overlap, then
fixed two integration seams the merge introduced and updated this
branch's tests to the maintainer's API:
- room_server: timeout used undefined `hops`; aligned to `path_len`.
- packet_router: PATH helper was invoked twice (maintainer's
  unconditional call plus this branch's conditional local-identity
  call); dropped the now-redundant conditional block.

Pin openhop_core to @dev (was @feature/publish-workflow-message-handling)
so this can merge to the repeater's dev; core dev carries the required
sender_prefix and PathUtils.is_valid_path_len APIs.

Full suite green (1040 passed) against openhop_core dev and
refactor/companion-housekeeping; ruff clean.
2026-07-07 12:56:43 -07:00
agessaman b2e45c2038 fix: resolve room-server push ACKs through the dispatcher
Room server pushes waited on dispatcher.wait_for_ack, but nothing in the
repeater could ever resolve it, so every push timed out and re-pushed on
the backoff schedule (issue #286's duplicate floods — worst for virtual
companions on the same instance, whose ACKs never even cross the air):

- no core AckHandler is registered (all RX lands in the router fallback),
  so received ACK CRCs were never fed to dispatcher ACK matching
- inject_packet waited on packet.get_crc(), a packet-hash CRC that no ACK
  sender produces; the crypto CRC only the room server knows was ignored
- PATH returns (how a flood-received DM is ACKed) were decrypted by
  PathHelper, but it read the encoded path_len wire byte as a raw count —
  an empty 3-byte-hash path (0x80) parsed as a 128-byte truncated
  payload — and the router's PATH branch never ran PathHelper for
  room/repeater destinations when any companion bridge existed

Fixes:
- router ACK branch feeds discrete ACK CRCs (RF and locally injected) to
  dispatcher._register_ack_received
- PATH packets addressed to a local server identity are processed by
  PathHelper regardless of companion bridges; PathHelper decodes the
  encoded path_len via PathUtils, keeps the encoded byte in out_path_len,
  and registers the embedded ACK via ack_received_fn
- inject_packet accepts expected_ack_crc/ack_timeout; the room server
  passes its crypto CRC and a hop-count-based timeout (the encoded byte
  would have produced a ~4-minute direct-push wait)

Verified live on a real mesh: push to a same-instance virtual companion
resolves via the PATH-embedded ACK (encoded path_len 0x80) and push to a
firmware client resolves via the discrete RF ACK, no re-push loops.

Fixes #286
Fixes #341
2026-07-07 11:17:54 -07:00
agessaman 9c6ea0151e fix: compute signed-post push ACK over the full signed span
push_post_to_client sent posts as TXT_TYPE_SIGNED_PLAIN (plaintext =
timestamp + flags + author_prefix + text) but computed its expected ACK
over the plain-DM span (timestamp + attempt byte + text). Signed-plain
receivers (firmware BaseChatMesh::onPeerDataRecv, and openhop_core's
text handler since it gained signed-message ACKs) reply with
sha256(decrypted[0 : 9 + strlen(text)] || client_pubkey)[:4], so the
server never recognized any push ACK: every push timed out and the same
posts were re-pushed forever (issue #286's repeating 'Push timed out'
log and duplicate unread floods).

Hash the exact plaintext span that is encrypted and sent, plus the
client pubkey. Verified against openhop_core's TextMessageHandler: the
ACK it emits for a pushed post now matches the stored pending_ack_crc.

Fixes #286
2026-07-07 10:35:28 -07:00
agessaman 0404b3ab44 fix: add blank-password read-only guests to the room ACL
A blank-password login replied success (read-only guest) without ever
creating an ACL entry or storing the ECDH shared secret. The client app
believed it was logged in, but the room server's text handler and sync
loop only see ACL members: the client's posts were dropped without a
delivery ACK (send shows failed) and posts were never pushed to it.
Rooms with only an admin password configured were fully affected since
every guest login is blank-password.

Add the guest to the ACL with guest permissions, the shared secret, and
sync_since (mirroring the password path), reject when the ACL is full,
and refresh activity timestamps on repeat blank logins.

Refs #286
2026-07-07 10:35:01 -07:00
agessaman 3a29061e24 fix: tolerate openhop_core without QueuedMessage.sender_prefix
Installed openhop_core releases predating the sender_prefix change
(paired with fd43d86) reject the kwarg, so restoring queued messages
raised TypeError and aborted companion init. Detect support via the
QueuedMessage signature and drop persisted prefixes with a warning on
older cores instead of failing the boot.
2026-07-07 07:53:49 -07:00
agessaman e6d4b68d01 fix: fail companion init loudly when persisted state cannot be loaded
A transient SQLite error during boot made companion_load_channels/
contacts/messages swallow the exception and return [], which is
indistinguishable from "no data". The Public-channel backfill then ran
over the empty store, so clients saw their channels wiped and later
saves could overwrite the persisted state.

- companion_load_{contacts,channels,messages} now return None on error
  vs [] for genuinely empty, and log the companion hash
- add companion_count_{channels,messages} helpers
- extract shared _restore_companion_state used by both the boot and
  hot-reload companion paths; each load is cross-checked against the
  table's row count for the hash, retried once after a short delay,
  and raises CompanionStateLoadError if it still cannot load, aborting
  companion init (and the Public backfill) instead of starting empty
- log restored row counts per companion; log when the channel store
  rejects a persisted channel (unchecked channels.set return)
- trim_companion_contacts_to_fit refuses to trim on a failed load
2026-07-07 07:43:27 -07:00
agessaman fd43d86ea8 fix: persist sender_prefix for signed room posts
TXT_TYPE_SIGNED_PLAIN room posts carry a 4-byte author pubkey prefix
(QueuedMessage.sender_prefix, added in openhop_core). The SQLite
persistence path dropped it, so posts replayed from persistence showed
a zero-padded author prefix in the app frame while posts synced from
the live in-memory queue were correct.

- add sender_prefix column (hex text, default '') to companion_messages
  with an ALTER TABLE migration for existing databases
- store/return the prefix in companion_push/pop/load_messages
- pass sender_prefix when rebuilding QueuedMessage from persistence in
  the frame server and the startup preload paths
- add round-trip, default, migration, and rebuild tests
2026-07-06 22:46:49 -07:00
agessaman 700a38b8c1 refactor: use public max_size property for offline-queue retention
Prefer MessageQueue.max_size (new public property in openhop_core) over
the private _max_size attribute, keeping a getattr fallback for older
cores that predate the property.
2026-07-06 16:38:45 -07:00
agessaman cd763e2cac feat: implement threaded stats broadcasting in StorageCollector
- Introduced a dedicated thread for broadcasting aggregate statistics to WebSocket clients, improving performance by offloading heavy queries from the packet write path.
- Updated the _publish_packet_sync method to ensure only fast, per-packet operations are executed, while the stats are handled separately.
- Added methods for broadcasting stats at intervals and managing the lifecycle of the stats thread.
- Enhanced unit tests to validate the new stats broadcasting behavior and ensure correct functionality under various conditions.
2026-06-18 21:38:20 -07:00
agessaman 2435757197 feat: enhance SQLiteHandler and StorageCollector for improved caching and async performance
- Added caching for packet type stats and cumulative counts in SQLiteHandler to reduce database load.
- Implemented a dedicated writer thread in StorageCollector to handle blocking storage operations, preventing asyncio event loop stalls.
- Updated record_packet method to utilize the new writer thread for efficient packet processing.
2026-06-18 21:25:19 -07:00
agessaman dcaa4ac949 Refactor contact trimming logic for improved readability by consolidating function calls into single lines. Update related test cases for consistency. 2026-06-14 15:50:03 -07:00
agessaman 23078de78b Enhance contact import logic to respect max_contacts limit and trim excess entries. Update tests to validate trimming behavior and ensure correct handling of favourites during import. 2026-06-13 18:26:35 -07:00
agessaman 0b4571cb68 Stop tracking policy.yaml and add it to .gitignore 2026-06-09 09:44:58 -07:00
agessaman 183650228e feat(config): add optional KISS CSMA tuning parameters
- Updated `config.yaml.example` to include optional KISS key-up and CSMA tuning parameters.
- Enhanced `get_radio_for_board` function in `config.py` to forward KISS tuning settings to the modem firmware when specified.
- Added tests to verify correct forwarding of KISS parameters and omission of unset values in `test_radio_config.py`.
2026-06-08 21:25:26 -07:00
agessaman f3146ebc14 fix(companion): clean up ruff errors 2026-06-06 09:44:55 -07:00