Skip to content

feat(app): ask a selected companion for its telemetry each cycle, zero-hop, and keep what it answers per node - #578

Open
khagele wants to merge 2 commits into
efiten:masterfrom
khagele:feat/553-companion-probe
Open

feat(app): ask a selected companion for its telemetry each cycle, zero-hop, and keep what it answers per node#578
khagele wants to merge 2 commits into
efiten:masterfrom
khagele:feat/553-companion-probe

Conversation

@khagele

@khagele khagele commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Closes #553

Stacked on #577 (Share my node name): the probe needs a target that has us as a contact, and it reuses selectedCompanionIds from that branch. That commit shows in this diff until #577 merges.

The problem

A companion selected as target got no probe: the auto-ping loop only trace-pings nodes that behave as repeaters, and the FAB label was the only hint. The firmware check in the #553 thread settled what a companion answers: not a directed TRACE (repeat is off by default), no anonymous request, no Discover, and one thing: a telemetry request from a sender it has as a contact, answered with its battery voltage and MCU temperature.

My changes in this PR

  • A companion target gets CMD_SEND_TELEMETRY_REQ (39) where a repeater target gets a trace-ping. One per auto-ping cycle, rotating over the selected companions (nextTelemetryTarget): the firmware keeps one pending telemetry tag (clearPendingReqs on every send), so two in flight would orphan a reply. The sweep stays trace-only. The FAB says "target ping" for a companion target too.
  • Nothing floods. The firmware floods a request when the contact's out_path_len is unknown and source-routes over a stored path otherwise. app/src/contactpath.js is the contact-path dance ported from efiten/coredrive-rx, tests ported with it: read the contact (CMD_GET_CONTACT_BY_KEY), force byte 35 to 0 for the ask (CMD_ADD_UPDATE_CONTACT), send, put the contact back exactly as it was. An override that does not ack means no ask this cycle. A session that dies between override and restore leaves a localStorage record, replayed on the next connect to the same companion.
  • Attribution (app/src/telemetryreq.js): the reply on the RX log is a RESPONSE datagram with only the 1-byte source hash. Like a trace reply (app: a trace reply we asked for lands as an anonymous point, although our own tag names its target #481) it is named after the node we asked while that ask is live and unambiguous; two live asks sharing a first byte are refused. The record gets sender_kind: 'telemetry_reply' and the target's full pubkey, so names resolve and the map has the node.
  • What it answered is kept per node. PUSH_CODE_TELEMETRY_RESPONSE (0x8B) carries a 6-byte prefix and the CayenneLPP payload (voltage type 116 in 0.01 V, temperature type 103 in 0.1 °C signed). It goes into a new nodes store in IndexedDB (schema v3, additive), keyed by pubkey. Nothing renders it yet.
  • Decision log: docs/2026-09-04-companion-telemetry-probe.md. Changelog entry in both copies.

What it needs on the other side

The target must have us as a contact (it looks the sender up by hash for the shared secret), which is what #577's Share my node name is for, and its telemetry permission must not be deny (firmware default deny, the MeshCore app sets it; companions answer in the field). Our companion must have the target as a contact, which auto-add gives it on the target's advert; NOT_FOUND skips the cycle.

Verification

Suite Result
app vitest 938 passed, build and eslint clean
web vitest 509 passed
web e2e whatsnew.spec.js, --fail-on-flaky-tests 13 passed (the changelog copies)

Unit tests cover the frames against the firmware layouts, the LPP parser (signed temperature, skipped and unknown fields, truncation), the ask matching (live, ambiguous, expired), the rotation, the contact-frame override and restore byte diffs, the crash-safety record, and the nodes store including the v2 to v3 upgrade with fake-indexeddb.

Browser at 360x780 with a stub companion answering the commands: a selected companion runs 30, 9, 39, 9 each cycle with byte 35 at 0 in the override and 0xFF in the restore, the ask is remembered and the restore record cleared; a 0x8B push lands 3.97 V and 25 °C on the node; a real captured Response packet with that source hash (the decode.test.js fixture) is captured as telemetry_reply with the target's pubkey; a NOT_FOUND contact sends no request; a failed override sends no request and keeps the restore record for the next connect.

Not verified here: a companion on air. The reply's route is the target's choice, direct when it knows a path to us and otherwise a flood of its own; we hear it zero-hop either way.

Decisions worth a look

  • 39 over path discovery (52): 52 always floods, and the companion firmware discards the telemetry in its reply (MyMesh.cpp:770, "telemetry data in 'extra' is discarded at present").
  • The restore runs right after the send ack, not after the reply: our contact's path only affects our send.
  • telemetry_reply stays outside TARGET_KINDS and the repeater reading, like trace_reply.

🤖 Generated with Claude Code

khagele and others added 2 commits September 4, 2026 16:15
… advert each cycle a companion is the target

The hunter's companion never announced itself (efiten#576): the app sends
Discover broadcasts and trace-pings, which carry no identity, and
nothing else. MeshCore firmware answers a request only from a sender
it can look up in its contact list, and a node adds us to that list
when it hears our advert. So a companion that has not heard us cannot
answer the telemetry request efiten#553 wants to send.

An advert is also the first time the app would transmit who the hunter
is, so it is a setting, off by default: Share my node name, in a new
Identity section of Settings, with the plain words under it. With it
on, every auto-discover cycle that has a companion among the selected
targets sends one CMD_SEND_SELF_ADVERT (7) with the route byte at 0,
which the firmware sends zero-hop, never a flood. Per cycle rather
than once at switch-on, because the target can be out of range at
that moment and it has to hear us before it can answer. A repeater
target keeps its trace-ping and gets no advert: selectedCompanionIds
is the selection minus the repeater reading, so the two never disagree
about one node. The FAB pulses and the tx cue sounds, as for every
frame that goes out. The setting lights the settings dot like every
other non-default. What the advert carries is the companion's own: its
public key and name, and a position only if the owner set the location
policy in the MeshCore app.

Verified: app 906 unit tests, build and eslint clean; web 509 (the
changelog copies). Eight mutation checks over the two rounds, each red
on one test. Browser at 360 with a stub transport: nothing selected
sends no advert; a companion selected sends one [7,0] per cycle; the
same node read as a repeater gets a trace-ping and no advert; the
setting off, or the selection cleared, sends none; the setting
survives a reload.

Closes efiten#576

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…o-hop, and keep what it answers per node

A companion selected as target got no probe (efiten#553). The firmware check
in the issue thread settled what a companion answers: not a trace
(repeat is off by default), no anonymous request, no Discover, and one
thing: a telemetry request from a sender it has as a contact, with its
battery voltage and MCU temperature. So where a repeater target gets a
trace-ping, a companion target now gets CMD_SEND_TELEMETRY_REQ (39),
one per auto-ping cycle rotating over the selected companions, since
the firmware keeps one pending telemetry tag.

Nothing floods. The firmware floods a request when the contact's
out_path_len is unknown, so the app runs the contact-path dance ported
from coredrive-rx (contactpath.js, tests ported with it): read the
contact, force out_path_len to 0 for the ask, send, put the contact
back as it was, whether the ask went out or not. An override that does
not ack means no ask. A session that dies between override and restore
leaves a localStorage record that the next connect to the same
companion replays.

The reply on the RX log is a RESPONSE datagram carrying only the 1-byte
source hash; like a trace reply (efiten#481) it is named after the node we
asked while that ask is live and unambiguous (telemetry_reply, full
pubkey). The 0x8B push that follows carries a 6-byte prefix and the
CayenneLPP payload (voltage type 116, temperature type 103); what it
says goes into a new nodes store (IndexedDB v3, keyed by pubkey),
apart from the receptions. Nothing renders it yet. The FAB says
"target ping" for a companion target too.

docs/2026-09-04-companion-telemetry-probe.md records the decisions:
39 over path discovery (52 always floods and the firmware discards
its telemetry), the three conditions, the cadence.

Verified: app 938 unit tests, build and eslint clean; web 509 and the
whatsnew e2e. Browser at 360 with a stub companion answering the
commands: a selected companion runs 30, 9 (byte 35 = 0), 39, 9 (byte
35 restored) each cycle, the ask is remembered and the restore record
cleared; a 0x8B push lands 3.97 V and 25 °C on the node; a real
Response packet with that source hash is captured as telemetry_reply
with the target's pubkey; NOT_FOUND sends no request; a failed
override sends no request and keeps the restore record.

Closes efiten#553

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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.

app: a companion can be picked as target, and then nothing ever probes it

1 participant