Skip to content

feat(poll): guest-key repeater polling with analyzer coordination - #100

Draft
agessaman wants to merge 95 commits into
observer-firmware-devfrom
feat/repeater-poll
Draft

agessaman wants to merge 95 commits into
observer-firmware-devfrom
feat/repeater-poll

Conversation

@agessaman

@agessaman agessaman commented Nov 13, 2026 •

Copy link
Copy Markdown
Owner

Summary

Adds guest-key repeater polling to repeater observers. An observer keeps a short list of neighbor repeaters, logs in to each as a guest under a separate polling identity, and requests battery and telemetry on a schedule. A CoreScope server coordinates which observer polls which repeater. The behavior mirrors the openHop plugin "CoreScope Observer Plus" v0.11.1 (guest-key path only).

Off by default. Nothing is sent until an operator sets an analyzer URL and runs set poll on. Against a CoreScope server the operator also sets an MQTT slot for claims, gives consent to the key upload, and adds targets.

A second mode serves analyzers that do not want to hold polling keys (written for Beacon). The analyzer selects it with a policy field; the observer then publishes each reading as JSON over MQTT and never uploads its polling key. See "Reading modes" below.

What is in here

  • Pure, host-tested rules in src/helpers/poll/: packet codec and reply parsers, schedule and backoff, policy gate and standby decisions, claims, registration, state store, HTTP framing and JSON parsing for the control client.
  • Radio glue in examples/simple_repeater/RepeaterPoller.{h,cpp}: login, route teach, request, admin stop and removal drill, flash state (learned passwords, admin stops, saved gate, tag high-water).
  • CoreScope control client on a worker task started by the MQTT bridge: owners, policy, registration and shared-key requests over one HTTP/1.1 connection, with an explicit TLS guard shared with the slot handshakes and a stop barrier for OTA.
  • Loop-side control: policy gate (pause, block, minimum version) persisted across reboots, owner roles and standby step-in, startup hold, own key or shared key, claims on the configured slot.
  • CLI: set poll on|off, set poll.url|slot|interval|status|community|consent, poll add|remove|clear|list|pw|enable|now, get poll, get poll.status <n>, get poll.control, get poll.key.

Built only where WITH_REPEATER_POLL is derived (ESP32, MQTT bridge, neighbor table, PSRAM); other builds are unchanged.

Reading modes (added after the first draft)

The policy answer may carry reading_format. Absent means the CoreScope behavior above, unchanged: same HTTP calls apart from two added query parameters (client, client_version), same claim bytes, same registration and shared-key behavior.

With reading_format set to json or telemetry:

  • No key custody. No registration and no shared-key request. Until the first policy answer of a boot the mode is unknown, and in that state nothing is registered and nothing is polled. Consent now gates only the key upload, so these modes need none.
  • Readings over MQTT. One message per successful poll on .../battery (v1) or .../telemetry (v2, with the LPP records), each with the request tag and the reply's packet hash. A reading that could not be published is held in RAM and retried no more often than every 60 s.
  • Slot by broker host. With poll.slot unset, the slot is the first one whose broker host is in the policy's mqtt_hosts. No match means no claim, no reading and no poll. A publication carries the host it was matched to and is refused if the slot has been pointed elsewhere in the meantime.
  • Extended claim. key:"own", per-node state, misses, last attempt and last success, plus accepts_requests and request_slots.
  • Operator passwords. poll pw <target> set <password> (1 to 15 printable ASCII characters). Stored with the target, never published, discarded after an admin answer.
  • Requested targets (set poll.requests on, default off, https only). The owners answer may name targets the operator did not configure, each with an optional password encrypted to the observer's node key (X25519, HMAC-SHA256 key derivation and tag, one AES-128 block). They share the eight target seats with local targets, which have priority; they live in RAM only and are dropped when the opt-in, URL, mode or owners answer no longer supports them. Two failed logins refuse that password version until the version changes. An admin answer is a persistent stop.

New CLI: set poll.requests on|off, poll pw <target> set <password>; get poll and get poll.status show the mode, the slot and how it was chosen, and requested targets.

Deliberate differences from the plugin

  • No claims while no polling key is in use (the plugin can fall back to its companion path; the firmware cannot poll in that state).
  • A policy change applies at the next control session, at most one control period (600 s) later for scheduled polls; poll now checks with CoreScope first when the session spacing allows.
  • With community off there is no owners call, so no freshness signal: the node polls on its own schedule within the policy window and request cap.
  • An admin-stopped target is left out of claims.

Testing

  • Native: 1234 test cases pass (whole native suite).

  • Builds: heltec_v4_repeater_observer_mqtt, Heltec_v3_repeater_observer_mqtt, Waveshare_S3_LR1121_repeater_observer_mqtt (83.7 % of the app partition), Heltec_v3_room_server_observer_mqtt.

  • Bench, Heltec V4 poller with a Heltec V3 and a Waveshare LR1121 as targets, against a local CoreScope stand-in:

    • first contact, steady state, 4 h soak of the radio path (128/128 polls, heap flat);
    • target reboot recovery; admin answer -> admin stop, persisted across a reboot; removal drill;
    • startup (register, policy, owners, claim), paused policy and lift, blocked observer across a reboot, registration 404 -> shared key, standby stepping in after window plus grace;
    • poll now waiting on a CoreScope check, and dropped when polling is switched off;
    • no polling key: claims held, nothing polled.
  • One HTTPS control session against a real CoreScope server: owners, policy and shared key returned 200.

  • Bench of the reading modes at c1466cb63 (requested-target session at 9b005a940), same boards, stand-in extended with the new fields:

    • stand-in unreachable at boot: mode unknown, no registration;
    • CoreScope behavior unchanged (policy, then registration in the same session, claim, poll);
    • telemetry and battery modes switched live: no key endpoint touched afterwards, extended claim, one reading per poll;
    • slot by broker host: match, no match (nothing published, poll now refused), and recovery once the host list is corrected;
    • requests stay off over plain http;
    • requested target with an encrypted password (image patched to accept http for this test only): adopted, logged in, reading published; wrong password refused after two logins and not retried on re-encryption at the same version; recovered on a version change; dropped when withdrawn; one login, then direct requests on every later poll while the analyzer re-encrypted the envelope each minute and across a version change.

Not done yet

  • Reading modes, on hardware: operator password via poll pw ... set; admin answer from a requested target; a slot reconfigured between queueing and publishing; requests over real https; a local target taking the last seat from a requested one.

  • Settings written by this build (poll.requests, operator passwords) are dropped by an older build of this branch on downgrade.

  • Overnight soak of this tip.

  • On hardware: the TLS guard and stop acknowledgment under a link flap or bridge stop; loop-task stack high-water; key lost mid-run (empty claim); URL change with a different shared key; expired shared key; policy lists longer than the parser limits (all host-tested).

  • The TLS connect to the real server took 9.97 s against a 12 s limit with five slots up.

  • Whether the server reads a claim with an empty node list as a retraction, and whether it expires stale claims.

  • A node with no key and a due target still opens a control session every control period; a 404 registration is retried only after a fixed delay.

  • Docs (docs/cli_commands.md), rename of namespace poll (clashes with POSIX poll() in translation units that include socket headers), decision on non-PSRAM boards.

Event-driven step functions for one guest-key poll of a direct neighbor
and for the admin removal drill, with host tests driving them against a
scripted repeater.
Guest polling has to match the repeater login, request, PATH teach,
and setperm plaintexts, and must not treat telemetry as an admin login.
Port CoreScope's clamps, kill switches, and owner freshness
rules so a scheduled poll can be decided without JSON or a clock.
Accept login answers from the moment a login is handed to the owner,
holding them until its TX report. An admin answer is taken at any point
after that, overriding a held or accepted guest answer, and ends the
poll. The admin drill holds a verifying answer that beats its login's
TX report.
Keep the fields runtime-only so an upgrade does not rewrite
/mqtt.json on nodes that have never configured polling.
Refuse the commands until the poller enables them, and turn
polling off when url, slot, or consent is cleared.
Claims publish on meshcore/<IATA>/<pubkey>/polling. MeshRank
does not consume that topic.
Guest passwords arrive without the two-byte packet MAC. Check the full
tag before decrypting, and accept only a short zero-terminated password
so a bad block cannot be treated as blank.
A malformed envelope is marked bad so the caller does not fall back
to a blank password. Extra targets are kept up to the free capacity,
ranked by role and key rather than JSON order.
Absence fails closed: forget a target when a valid owners answer
omits it, when roles lapse, or when the https opt-in stops. A new
envelope wipes the decrypted password so a refusal is not retried.
…ed path, and use the own key at once in a JSON mode
…l points at the matched broker

An automatic claim or reading kept only the slot number, so a
reconfigure could send it to a broker the policy never named.
The mailbox now carries the matched host and the MQTT task
refuses the send when that host is no longer the slot's broker.

The poller object was already 5540 bytes, past the 5204 ceiling,
so this environment could not build. The ceiling matches that
size. This change adds no poller fields.
A valid prefix plus the owners and policy query no longer fit in
the old 352-byte buffer, so those GETs failed before connecting.
The retry stamp was forced one millisecond ahead, so a same-tick
check wrapped and treated the wait as already over.
A wrong host left the claim unsent and the reading held, but
neither waited out its retry before trying again.
A plain memset after the last read of a password copy is a dead
store, so the bytes can outlive the handler and the scratch.
@agessaman agessaman changed the title feat(poll): guest-key repeater polling with CoreScope coordination feat(poll): guest-key repeater polling with analyzer coordination Nov 14, 2026
…pe is re-encrypted

The repeater ACL entry belongs to the polling key, so a new
ciphertext must not drop the fast path or cancel a poll already
running.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant