Firmware simple_repeater compares a blank password from a sender not in the
ACL with guest_password, which defaults to empty, so anyone may log in as a
guest and fetch base telemetry. The repeater refused it unless
allow_read_only was on. Room servers are unchanged.
The Waev preset's header described a router constant and a 1500 ms
broker-A-then-B failover that Waev's router no longer has, and called
mqtt-a.waev.app "force primary, no failover". Waev routes each
connection to one of two broker pools and fails over to the other; the
pool hostnames only choose which is tried first.
Describe that without naming Waev's internals. The override example in
config.yaml.example named a broker, waev-b, that the preset does not
have, so it did nothing; it now prefers pool A by overriding the host.
The two test docstrings that repeated the old claims follow.
build_radio_stack pops _radio_id before building each radio, so the
warning never named the entry on a real node. A _radio_label survives the
pop, and the warning now points at radios[<id>].radio, saying the value is
inherited only when it is.
config.yaml.example documents sync_word.
A table full of admins refused every newcomer, and now that entries outlive
a restart that lockout was permanent; the upgrade placeholder made it likely
with max_clients: 1. The placeholder is now 32 (firmware MAX_CLIENTS), and
an admin password login or setperm to an admin role evicts the admin seen
least recently, by its stored last login after a restart. Blank-password and
guest newcomers are still refused, so they cannot strip admins one by one.
An eviction now waits for the newcomer: a refused login (a replay) puts the
evicted entry back, and its stored row is deleted only once the newcomer's
grant is stored, or once that grant's next write succeeds after a failure.
Evicted clients' replay watermarks are kept (capped, in RAM) so an evicted
admin's captured login cannot be replayed when it comes back.
unregister_identity also detaches an ACL listed only by name, after a hash
collision displaced it from the hash index.
Fourth review round (opencode; codex hit its usage limit):
- update_identity's hot reload was always refused: the identity manager
rejects a name or hash already registered, which the running room's own
entry always is. The ACL name index then lagged the config, so a renamed
room was unmanageable by name, and a later rekey left a second live,
writable ACL. The old entry is now unregistered first and restored if
the new registration is refused (IdentityManager.unregister_identity),
and a move falls back to the store key when the name misses.
- A key change whose save and cleanup both failed left a copy at the new
key that blocked every retry. A copy under the room's own label is
replaced on retry; another identity's entries still refuse the move.
- A registration now detaches the ACL it replaces before loading, warns
when it displaces another identity sharing its hash byte, and the no-store
guard for a key change lives in LoginHelper.move_room_acl.
- /acl_stats is documented as it has always answered; store_error and
store_errors say they clear once a later read succeeds; the example
config says room servers keep their own security settings.
Review (codex, opencode) found:
- A room renamed and rekeyed in one update lost its ACL, and a room later
created under the old name could adopt it. Room servers are no longer
adopted by label; update_identity moves their rows to the new key and
name in one transaction and points the live ACL at them. Only the
repeater, which is unique, still adopts by label after a key change.
- A removal racing a grant could leave the grant stored but not listed.
Each ACL change and its write now happen under one lock.
- A failed write was reported as OK. The store now raises; setperm answers
"Err - failed to save" and leaves the table as it was, and the API
returns an error. A login still succeeds, as its session is valid.
- Re-registering an identity (a rename) built a new ACL, zeroing every
session's activity and replay watermark, so the room sync loop stopped
pushing to logged-in clients. The live ACL is now reused.
- The repeater's max_clients default rises to firmware's 32: admins are
never evicted and now persist, so 5 or 10 filled permanently. Stored
entries past max_clients all load, with a warning.
- atoi takes ASCII digits only; non-repeater identity types get their own
label; the sync-loop skip logs at debug; `get acl` has help.
API for the web UI:
- POST /api/acl_set_permissions sets an entry by identity name.
- /api/acl_clients lists entries by identity name rather than the 1-byte
hash two identities can share, with permissions_value, persisted and
identity_pubkey. /api/acl_info adds acl_entries and stored_entries, and
authenticated_clients counts entries that have been active.
- /api/acl_remove_client reads client_pubkey, as its schema always said
(the UI sent it, and the endpoint read only public_key), and takes
identity_name.
Multi-radio entries currently replace radio, sx1262, and transport
configuration sections wholesale. Partial sx1262 entries therefore discard
board settings supplied by the top-level hardware preset, including TCXO
control, DIO2 RF switching, and radio enable pins.
Merge mapping sections key-by-key instead, treating the top-level sections as
defaults and radios[] values as per-radio overrides. Keep radio_type as a
scalar override so heterogeneous fabrics continue to work.
Overlay makes two spellings of one setting reachable together, where the
entry's could lose to an inherited default it never wrote: en_pins beats
en_pin in SX1262Radio, and address/serial_number beat device_address/serial
in get_radio_for_board. An entry naming either spelling now displaces the
other, so the failure mode is not a radio that silently never powers up.
This also corrects duty-cycle metering. It builds its air profiles through
_merge_radio_entry, so a radios[] entry that set only its frequency was
metered on library defaults -- 62.5 kHz / SF8 / CR8 -- rather than on the
node's own top-level radio block, and reported no tx_power at all.
_inherited_air_profile's docstring already described the overlay semantics
this commit implements.
Inheritance reaches the MQTT radio map the same way, and there it needed a
guard. The map is rebuilt from config on every status publish, so once a
partial entry inherits the top-level radio block, saving the default radio's
bandwidth moved the reported bandwidth of every radio that omits one --
while only the default radio is retuned, the rest keeping their band until a
restart. Observers join that map against rx_radio_id to decide which band
carried a packet, so it would have attributed those radios' packets to a band
they were never on. build_radio_status_entries now takes the map built when
the radios were last really configured and keeps every non-default entry from
it, which is the line AirtimeBudgets._effective_air already holds for
metering; MeshCoreToMqttPusher seeds it on its first build.
Log each resolved radio config at debug level, redacting modem_tcp.token via
redact_modem_tokens_in_place, since what an entry inherited is otherwise
invisible and a wrong answer surfaces only as a radio that will not transmit.
Add regression coverage for TCXO/RF-switch inheritance, explicit false
overrides, partial radio air settings, CH341 device selection, per-radio
radio_type overrides, alias displacement, metering inheritance, token
redaction, and a live default-radio save leaving other radios' published
air settings alone.
Fixes#485
A radio id resolved to its frequency and modulation, which is enough to say
which band a packet was on but not how hard it was pushed. Two radios on one
node routinely run different power -- a wide local radio turned down to keep
off its neighbour, a narrow backhaul at full -- and nothing in the air settings
says so, which leaves an observer comparing their coverage with a term missing.
Each entry in the status `radios` map gains `tx_power`, in dBm.
The value is read through build_radio_profiles, so it inherits the same merge
and per-radio override rules as the rest of the profile rather than a second
reading of the config. get_radio_for_board applies a different default per
radio type -- 14 for kiss, 22 for the modems, and none at all for sx1262, which
requires the key -- so those defaults now live beside the other air defaults and
a config that is silent reports what is actually on the air.
Where the power cannot be known it is omitted rather than guessed, and unlike
the air settings it never voids the map: power does not decide which band a
packet was on, so an entry without it still attributes the packet correctly.
Single-radio nodes publish no map at all, as before.
Everything the map reports is what the node is configured with, not a reading
from the hardware, and the docstring now says so: a driver may clamp what it was
given (an SX1262 holds -9..22 dBm), and editing a radios[] entry does not
reconfigure that radio live, because config_manager only applies the top-level
radio section. That gap predates this field -- it applies to the frequency and
modulation already in the map -- but a power figure invites the comparison, so
it is written down rather than left to be discovered.
A boolean reads as unknown rather than as int(True)'s plausible-looking 1 dBm,
and an unreadable optional field logs at debug: this runs once per status
publish, so a warning there would repeat for the life of the node.
A two-radio bridge is one MeshCore node with one pubkey, and the path
byte it appends on repeat is the same whichever radio carried the
packet. Nothing on the air says which band a hop used, and an observer
receiving on one side can only infer the other by already knowing its
neighbour's frequency. The node itself does know, so say it.
Two halves, both needed:
- Packets gain rx_radio_id (the radio that heard it) and tx_radio_ids
(every radio that retransmitted it - a bridge repeating on both sides
lists both). tx_radio_ids is always a list, so a consumer never has to
branch on the JSON type, and is absent rather than empty when the
packet was not repeated.
- Status gains radios: [{id, radio}], mapping each id to its
MHz,kHz,SF,CR settings. Without it a radio id is just an
operator-chosen label. Ids are unique only within a node, so consumers
key on (origin_id, id). Rebuilt per publish, since air settings can
change from the web UI while the node runs.
Single-radio nodes publish neither and their payload stays byte-for-byte
what it was: the ids would say nothing the status message doesn't
already. A node whose radio settings cannot all be read publishes no map
at all rather than a partial one that would place a packet on the wrong
band.
On a Fabric node the top-level status radio field now reports the
default radio rather than the top-level radio: block, which is the base
entries inherit from and can describe a band no radio is on.
A duty cycle is a limit on a channel, not on a node, and one AirtimeManager
charging every radio at the top-level radio block's modulation is wrong twice
over on a bridge. The same packet occupies a 62.5 kHz channel about eight times
longer than a 500 kHz one, so whichever bandwidth that block names, the other
radio is mis-charged by that ratio; and the two share one allowance, so a busy
local radio spends the backhaul's airtime on a band it never transmits on.
There is now one budget per channel, built from each radio's own profile and
its own band's limit -- 868.0-868.6 MHz allows 1% where 869.4-869.65 allows
10%, and one number cannot describe both. Two radios tuned alike share a
budget, because they contend for the same spectrum. duty_cycle.shared_budget
restores a single node-wide allowance for anyone who wants the conservative
reading, and a single-radio node is metered exactly as it was.
Metering a channel means knowing which radio will carry a packet, and the
engine used to work that out by restating openhop_core's dispatch rules. It
asks now. openhop_core passes a packet's ingress radio to the TX policy, so the
choice is a function of the packet rather than of whatever arrived most
recently; the repeater asks before scheduling and gets the answer the send will
reach. That also closes a race that predates this: sticky and bridge read the
node's last RX at send time, which after a retransmit delay on a busy node is
another packet's arrival, and moves a reply onto the wrong band.
The core change is optional. The keyword in resolve_tx_radio_id's signature is
the version check, and against a core without it the tx_mode selectors are
registered in their original one-argument form and behave exactly as they did.
What changes there is the gate: with no way to know which radio will transmit,
every radio the fabric could pick is held to its own budget, so a node cannot
transmit past a legal limit by guessing the quiet channel. Which mode is in
force is logged at startup.
Also fixed, each reproduced by an adversarial review of the earlier attempt:
- A node with one radios[] entry had its modulation and limit overwritten by
the stale top-level block on every radio save from the web UI. The refresh
path now branches on whether profiles were read, not on how many radios
there are.
- build_radio_profiles is all-or-nothing by design -- it feeds the UI -- so one
radio_type: none collapsed metering onto the top-level block for the radio
still transmitting. Metering has its own builder that degrades per radio.
- Carrying the whole node's spend onto an unrecognised channel compounded over
repeated rebuilds (100 -> 400 -> 1600 ms) and wedged transmission for the
rest of the window. Carry-over is keyed by channel, which makes an unchanged
rebuild idempotent, and an unrecognised one starts from the busiest single
channel rather than the sum of them.
- A rebuild worked the previous default manager out from the config again,
which by then already named the new default -- preserving the wrong object
and orphaning the one NeighborScopeHelper holds for the life of the process.
It is remembered rather than recomputed.
- Every caller that reports one airtime figure was reporting the default
radio's channel: the MeshCore wire field total_air_time_secs, companion
stats, the RRD and SQLite history, and /stats. They read a node total again
-- totals summed across channels, utilisation the highest of them, because a
node at its limit on one channel is at a limit however quiet the other is.
tests/test_fabric_fanout.py's rig never bound a tx_mode selector, so all 49 of
those tests ran against a fabric with no TX policy at all. It binds the real
one now; that gap is why an on-air change went unnoticed through two reviews.
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.
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.
- Introduced tests for the plugin catalogue installation and update flows in `test_plugin_catalogue_install.py`.
- Added unit tests for the GitHub Releases plugin client in `test_plugin_github_releases.py`.
- Implemented IPC tests for the plugin manager Unix socket protocol in `test_plugin_ipc.py`.
- Created lifecycle and installation tests for the plugin manager in `test_plugin_manager_lifecycle.py`.
- Added tests for plugin manifest parsing in `test_plugin_manifest.py`.
- Developed tests for plugin filesystem layout and state management in `test_plugin_storage.py`.
- Added tests for multi-radio stack functionality, including merging radio entries and building radio stacks.
- Implemented policy context tests to ensure correct handling of radio IDs in multi-radio scenarios.
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.
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.
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.
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.
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.
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).
standardize default region on mesh.default_region (remove legacy region_default_scope usage)
add/align CLI support for owner.info, path.hash.mode, and loop.detect with validation
wire UI terminal get/set + autocomplete/help for owner.info, path.hash.mode, loop.detect
extend update_radio_config to persist owner_info for UI set owner.info
add and use shared packet utility for advert creation + default-region transport scoping
refactor repeater and room-server advert paths to reuse shared packet logic
- 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`.
- Updated LogBuffer to support log entry IDs, enhanced log entry structure with additional metadata, and implemented subscriber management for real-time log streaming.
- Added OpenAPI specifications for new endpoints related to policy management, including retrieval, updating, validation, and group management for network policies.
- Implemented comprehensive tests for new policy endpoints, ensuring correct behavior for creating, updating, validating, and deleting policy groups and entries.
- Introduced policy evaluation tests to validate the functionality of the PolicyEngine, including various scenarios for action decisions based on defined rules.
- Enhanced packet routing tests to ensure proper handling of policy decisions in packet processing.