Commit Graph
1295 Commits
Author SHA1 Message Date
agessaman 783f125ff2 chore(web): rebuild bundled UI assets 2026-09-12 18:05:18 -07:00
agessaman 366cc2f874 fix(analytics): keep the neighbour history queries as fixed SQL
Adding the radio filter and per-radio split by concatenating SQL fragments tripped bandit B608, failing the pre-commit check. The fragments were constants and every value was bound, but the queries are now three fixed strings: a NULL radio parameter disables the rx_radio_id filter, and the per-radio split selects its own query. Results are unchanged.
2026-09-12 17:40:05 -07:00
agessaman 8fdab2340c chore(web): rebuild bundled UI assets
Picks up the per-radio analytics fixes from openHop_RepeaterUI feat/per-radio-airtime: settled and generation-checked loads, whole-minute chart buckets, per-radio duplicate series, and the radio selection kept in ?radio=.
2026-09-12 17:33:29 -07:00
agessaman c5ecd70ccb docs(api): describe /neighbor_links data as the endpoint returns it
The endpoint echoes limit and accepts a fractional active_within_seconds; the schema omitted the first and declared the second an integer.
2026-09-12 17:32:41 -07:00
agessaman a37be4b2f7 fix(neighbours): only treat a reception as our echo while that send is on air
The self-echo guard registered a packet's hash and forwarded path when the
engine decided to forward it and kept it for 30 s. Two things could then
hide a real neighbour from the link tracker:

- A neighbour whose 1-byte path hash collides with ours, repeating the
  same packet from the same upstream path, carries exactly the path we
  would have sent.
- The registration happened before the duty-cycle gate, so a packet that
  was never transmitted still suppressed matching receptions.

Registration now happens at the physical send, inside the TX lock after
the duty-cycle gate, and records which radio is sending. A send that
fails is forgotten. A reception counts as our echo only when it arrives on
a radio other than the one that sent it, and while that send is on the
air or within 2 s of it finishing, which covers a remote modem's delivery
delay. Single-radio nodes keep no records at all, since a lone radio
cannot hear itself.

A colliding neighbour is now mistaken for our echo only if it repeats the
same packet on the same path, on the other radio, inside that window.
2026-09-12 17:32:41 -07:00
agessaman f5368306aa chore(web): rebuild bundled UI assets
Picks up the per-radio Statistics and Neighbour Links views, and the RF Health multi-radio labelling, from openHop_RepeaterUI feat/per-radio-airtime.
2026-09-12 17:06:14 -07:00
agessaman da7c3997c3 feat(neighbours): attribute link observations to their radio, ignore our own echo
The engine now passes each reception's radio to the neighbour link
tracker, from both the forwarding path and the duplicate-recording path,
so /neighbor_links can report how each radio hears a neighbour.

Two radios sharing one frequency hear each other transmit. The echo of a
relayed flood ends in this node's own path hash, so the tracker recorded
the node as its own neighbour, at near-zero-dB signal. The engine now
remembers the packet hash and exact path of each transmission for 30 s,
registered before the first send because an echo can arrive while a
later egress of the same fan-out is still on the air, and skips tracker
observation for a reception that matches. A neighbour repeating our copy
appends its own hash and still counts. The echo is still stored and
published as the reception it is; only the neighbour table ignores it.

Matching the exact path rather than the last hop's hash keeps a real
neighbour whose 1-byte hash collides with ours.
2026-09-12 16:53:10 -07:00
agessaman e4f882d6b3 feat(analytics): break packet stats, routes and neighbour links down per radio
On a Fabric node the analytics endpoints merged every radio together, with
one transmission per logical packet and nothing to tell a reception on one
radio from the other. Every packet row already records its ingress radio
and each radio that sent it, so the split needs no new data.

The attribution rules get_airtime_buckets introduced move into shared
helpers: packet_carriers() says which stored radio ids a row occupied, and
RadioResolver maps them onto the configured radios (one radio owns every
row; with two, an unknown id is unattributed, never guessed).
get_airtime_buckets now uses them with no change in output.

With two or more radio profiles:

- /packet_stats adds radios[] (received, duplicates, dropped, physical
  transmissions, average RSSI/SNR) and unattributed counts. The legacy
  totals come from the same grouped pass, so the 24 h aggregate the
  WebSocket broadcasts does not gain a second scan. Totals still count
  one transmission per packet.
- /route_stats adds the route mix of each radio's receptions, plus
  originated for packets no radio received.
- /radio_packet_rates (new) returns receptions and transmissions per
  radio per bucket, grouped in SQL on columns idx_packets_airtime covers.
  A week-long window is an index-only scan instead of a Python loop over
  every row: 0.17 s against 1.11 s for the airtime loop on a synthetic
  1.55M-row table.
- /neighbor_links gives each link radios[], its stats as each radio heard
  it. The tracker keeps them beside the merged figures without changing
  its keys or entry count.
- /neighbor_link_history rows carry rx_radio_id, and the endpoint takes a
  radio_id filter and a by_radio bucket split.

A single-radio node gets exactly the answers it had: the legacy queries
still run for it and every new field is omitted.
2026-09-12 16:53:10 -07:00
agessaman 2494e0cf32 fix(airtime): keep the airtime chart query index-only
get_airtime_buckets selects rx_radio_id, tx_radio_id and tx_radio_ids to
charge airtime per radio, but idx_packets_airtime only covers timestamp,
length, payload_length and transmitted. SQLite therefore fell back to
idx_packets_timestamp with a heap lookup for every row in the window:
the access pattern the covering index was added to remove, and the one
that pushes the chart past the client timeout on an SD card. The test
meant to guard this asserted the plain timestamp index.

Migration 18 drops idx_packets_airtime and rebuilds it with the three
radio id columns. CREATE INDEX IF NOT EXISTS never reshapes an existing
index, so the base schema cannot do this. It keeps creating the original
four-column shape, which is safe on a database that predates the radio
id columns, and the migration widens it once migrations 16 and 17 have
added them. The rebuild reads the packets table once, at startup.

The bucket query is now a module constant shared with the test, which
asserts COVERING INDEX idx_packets_airtime for both airtime queries, so
a column added to the query cannot slip out of the index unnoticed.
2026-09-12 16:29:15 -07:00
agessaman 071196cb2b refactor(fabric): rename local_tx_mode to origin_tx
openhop-dev/openhop_core#123 settled on fabric.origin_tx: default | all
as the name for sending locally originated packets out on every radio,
and openhop-dev/openhop_core#124 with openhop-dev/openhop_repeater#412
implement that knob in core. Use the same key here so a Fabric config
means one thing whichever implementation lands.

local_tx_mode is still read, so a config written against the first
version of this branch keeps its behaviour. If both keys are set to
different modes, startup rejects the config before any hardware is
opened, since there is no way to tell which one the operator meant.
While running, the engine reads the option through the same helper and
lets origin_tx win. Validation errors name the key the operator actually
wrote.

The radio stack meta and the startup log report origin_tx.
2026-09-12 15:06:47 -07:00
Adam Gessaman b9fdeb56f1 Merge branch 'dev' into feat/fabric-bridge-repeat 2026-09-11 23:08:22 -07:00
agessaman 5b1fea226a chore(web): rebuild bundled UI assets
Picks up the per-radio airtime panels and their hover readout from
openHop_RepeaterUI feat/per-radio-airtime, on top of its dev at f1a5fb5.
2026-09-11 22:15:30 -07:00
agessaman 76c1a69ca4 feat(airtime): attribute airtime to the radio that carried it
A two-radio Fabric bridge runs each side at its own SF, so one combined
utilization line computed from a single caller-supplied profile describes
neither radio. Report airtime per radio instead.

build_radio_stack now records the effective air settings of every radio it
builds, in configured order, as radio_stack_meta["radio_profiles"]. The
same builder is exposed as build_radio_profiles() so /stats and the airtime
endpoint can read current settings from the live config rather than a boot
snapshot. Air settings are read the way get_radio_for_board reads them, and
a field that cannot be read is reported as null rather than defaulted, so
nothing is charted from settings that were never on the air.

get_airtime_buckets takes those profiles and charges each stored packet to
the radios that actually carried it: its ingress rx_radio_id and every
successful egress in tx_radio_ids, falling back to the scalar tx_radio_id
for pre-fan-out rows. A relayed packet therefore appears as RX on the radio
that heard it and TX on each radio that sent it, each at that radio's own
time-on-air. Duplicate receptions are included; they occupied the receiver.
With one profile every packet belongs to that radio, so single-radio and
pre-Fabric databases are attributed exactly as before. With two or more, a
packet whose radio cannot be identified is counted as unattributed rather
than guessed onto the default radio.

The response is additive: legacy buckets/rx_total/tx_total remain as the
combined series, and radios[] carries one series per radio alongside
unattributed_rx_count/unattributed_tx_count. Airtime comes from the shared
core estimator instead of a second inline Semtech formula, memoised per
(radio, length). The sf/bw_hz/cr/preamble query parameters are kept only as
a fallback; server profiles are authoritative and are never applied across
both radios of a bridge.
2026-09-11 21:22:06 -07:00
Rightup 9e375da776 fix(dependencies): update openhop_core to use core_dev git repository 2026-09-11 09:18:11 +01:00
Rightup 22d2721abc fix: AitTime Chart 2026-09-11 09:18:11 +01:00
agessaman f510aedf78 feat(fabric): repeat on both sides of a two-radio bridge
Add two Fabric options for an exactly-two-radio node:

- fabric.repeat_on_ingress (tx_mode: bridge): a relayed packet is sent
  on the other radio first, then back out on the radio that heard it.
- fabric.local_tx_mode: all: locally originated packets (companions,
  adverts, room/protocol replies) go out once on every radio, default
  radio first.

MeshCore processing (policy, dedupe, path mutation) still runs once per
logical packet; only the physical TX is duplicated, each egress with its
own Packet, serialised through the TX lock and gated independently for
duty cycle and link health. One successful egress is a successful
forward. Multi-ack extras share the egress set.

inject_packet registers its single ACK waiter before the first TX so an
ACK heard mid-fan-out outlives the dispatcher's ACK cache. Fan-out fixes
flood scope and path-hash mode once before copying and metering, so all
radios carry identical bytes and the duty-cycle gates see them.

Packet records gain tx_radio_ids (JSON column + migration); tx_radio_id
remains the primary successful egress. Invalid option combinations are
rejected before hardware is opened. Defaults preserve existing behaviour.
2026-09-10 17:16:27 -07:00
Lloyd c02b3cb738 Merge pull request #469 from Treehouse-00/feat/ws-companion-frame-api-key 2026-09-10 19:18:30 +01:00
Treehouse-00 56e2fd6417 feat(ws): accept API tokens on /ws/companion_frame
The companion frame proxy only accepted an operator JWT in `?token=`,
while `/ws/packets` also accepts a long-lived API token (X-API-Key
header, or the same query parameter). Apply the identical rule to the
frame proxy so a client that already holds a repeater API key can open
the companion link on the HTTP port without keeping a JWT session alive.

No behaviour change for JWT clients. Same fallback order, close codes
and log lines as PacketWebSocket.
2026-09-10 10:22:20 -07:00
Adam Gessaman 4705c99c38 Merge pull request #466 from agessaman/fix/plain-flood-default-scope
fix(engine): preserve scope on relayed flood packets
2026-09-09 19:36:51 -07:00
agessaman c91b331c93 fix(engine): preserve scope on relayed flood packets
After mesh.default_region was wired to the core dispatcher's send-time
scope resolver, a relayed plain flood was indistinguishable from a
locally originated one, so Dispatcher._apply_flood_scope() converted it
to the repeater's default transport scope and sent it out-of-region.

Successful flood forwards now mark their existing scope decision as
authoritative before entering Dispatcher.send_packet(): relayed plain
floods stay plain and relayed transport floods retain the originator's
codes. Locally originated floods and REPLY_SCOPE_DEFAULT fallback
replies still pick up the configured default scope.

Marking only on the flood_forward() success path leaves packets rejected
by validation, policy, duplicate, or loop detection untouched, and
leaves locally injected transmissions (which bypass forwarding) alone.
2026-09-09 12:58:37 -07:00
Lloyd 47e49e64aa Merge pull request #463 from openhop-dev/main
fix legacy restart interrupted.
2026-09-09 00:22:36 +01:00
Lloyd 13eb8b2ea8 Merge pull request #462 from openhop-dev/fix-restart-fix
Fix restart fix
1.1.4
2026-09-09 00:01:52 +01:00
Rightup 7dddebde73 test: add case for legacy restart interrupted being likely successful 2026-09-08 23:36:15 +01:00
Rightup fb92b0c4eb feat: implement legacy restart fallback for non-root service restarts 2026-09-08 23:13:07 +01:00
Lloyd b730e9eda9 Merge pull request #461 from openhop-dev/main
docker and version api changes
2026-09-08 22:58:29 +01:00
Lloyd 3a291a1b94 Merge pull request #460 from yellowcooln/fix/main-tagged-docker-release
ci: publish main Docker images only from stable release tags
1.1.3
2026-09-08 22:36:49 +01:00
yellowcooln 883022c7e5 style: format Docker release regression tests 2026-09-08 17:31:28 -04:00
yellowcooln 582ef4d8e7 ci: publish main Docker images only from stable release tags 2026-09-08 17:27:45 -04:00
Rightup fd2571b6e7 fix: ensure exact tags resolve correctly in version fetching logic 2026-09-08 22:26:15 +01:00
Lloyd d0a34d16e2 Merge pull request #459 from openhop-dev/dev
Merge dev into main: radio, plugins, routing and platform updates
1.1.2
2026-09-08 22:00:16 +01:00
Rightup 55e841189a fix: pin openhop_core dependencies to version 1.1.3 and update related documentation 2026-09-08 21:42:02 +01:00
Rightup 28dcfa15a0 feat: Added a QR action next to each neighbor pubkey 2026-09-08 20:48:59 +01:00
Lloyd ffd239dd82 Merge pull request #458 from yellowcooln/fix/sync-stock-meshcore-presets 2026-09-08 20:18:16 +01:00
yellowcooln 547c178268 docs: clarify upstream source in preset info message 2026-09-08 15:13:45 -04:00
yellowcooln 5fcb457f24 fix: synchronize radio presets with stock MeshCore
Match the preset list from MeshCore's official configuration feed one-to-one, including upstream ordering and preset metadata.
2026-09-08 15:09:55 -04:00
Lloyd 0e726f0700 Merge pull request #456 from openhop-dev/merge-pr-454-455
Merge pr 454 455
2026-09-08 17:16:51 +01:00
Rightup cc4be95df1 Merge branch 'pr-455' into merge-pr-454-455 2026-09-08 16:39:19 +01:00
Rightup ef1355f0e8 Merge branch 'pr-454' into merge-pr-454-455 2026-09-08 16:39:19 +01:00
yellowcooln 054ddd0784 fix(manage): render container warning with real line breaks 2026-09-08 11:22:07 -04:00
yellowcooln e4276b4273 fix(service): schedule restarts outside the daemon process group 2026-09-08 10:26:11 -04:00
kyleyank 004723d7cf Add Philadelphia preset 2026-09-08 09:33:43 -04:00
yellowcooln e47a68e021 fix(auth): bound and expire login throttle state 2026-09-08 09:29:31 -04:00
yellowcooln 0969335dfc fix(config): preserve persisted and runtime state on save failure 2026-09-08 09:29:31 -04:00
agessaman ab7479c55c fix(companion): re-assert SSE push callbacks on every stream open
The SSE endpoints registered their bridge callbacks once behind a
_callbacks_registered latch. Anything that cleared the bridge's push
callbacks therefore killed the stream permanently — the latch stayed True
while the subscriptions were gone, so no later stream open repaired them.

Cache the callback objects instead and re-register them each time a stream
opens. Bridge registration is idempotent, so the repeat costs nothing while
a subscription that went missing is repaired rather than leaving the stream
silently dead until the daemon restarts.

The clear that made this reachable is fixed in openhop_core, where the
frame server now retracts only its own subscriptions instead of clearing
every callback on each companion client connection. This half is the
belt-and-braces: clear_push_callbacks() remains a legitimate full reset,
and the stream should survive one.
2026-09-07 14:50:47 -07:00
Rightup 5409029a30 fix: fix plugin padding and add toast alert for new updates. 2026-09-07 22:35:29 +01:00
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