Advertise receiver esphome_version through the peer-link handshake (7a-2 prerequisite)#557
Merged
Conversation
Unblocks the deferred version-compat gate in pick_build_path
(7a-1) by capturing the receiver's bundled esphome version on
every peer-link session-open and threading it onto
StoredPairing + PairingSummary so the scheduler can read it
when deciding LOCAL vs REMOTE.
Wire change:
* Receiver's _send_response now ships
{intent_response, esphome_version} instead of just
{intent_response}. The version comes from
esphome.const.__version__ so it tracks whatever esphome
the receiver imported. Applies to every intent
(preview / pair_request / pair_status / peer_link) -- same
shared field on every response so a caller for any flow
gets the receiver's version alongside the discriminator.
Offloader-side capture (long-lived peer_link path):
* _run_one_session lifts esphome_version off the
decoded response. Older receivers that don't carry the
field land as empty string via the isinstance guard.
* _fire_opened carries the version onto
OFFLOADER_PEER_LINK_OPENED; the event's TypedDict gains
the field.
* The controller's _on_offloader_peer_link_opened
listener updates the matching StoredPairing and
schedules a debounced disk write. Same-version reconnect
short-circuits. Empty version (older receiver) leaves the
stored value alone -- clobbering with empty during a
mixed-version rollout would lose the previously captured
version.
Persistence:
* StoredPairing.esphome_version: str = "" with a max-64
validator. Empty default keeps old sidecars loading
cleanly through mashumaro's missing-field handling.
* PairingSummary.esphome_version: str = "" mirrors the
stored shape onto the wire projection. The
_pairing_summary_for projector threads the field through.
Tests:
* Unit: test_send_response_advertises_esphome_version reads
back the plaintext body off session.encrypt's call args
and asserts the JSON dict carries both fields.
* Unit: test_peer_link_opened_refreshes_stored_pairing_version
pins the update / no-op-on-same-version / no-op-on-empty
branches.
* Unit: test_peer_link_opened_for_unknown_pin_is_silent_no_op
pins the defense-in-depth gate for an OPENED firing after
the matching row was removed.
* E2E:
test_paired_instances_snapshot_carries_receiver_esphome_version
drives the full wire-and-capture chain through a real
Noise XX handshake; asserts the offloader-side snapshot
reports the receiver-process's bundled esphome version.
No behaviour change to pick_build_path itself -- that's the
follow-up. This PR just lands the field; the scheduler can
start reading it once 7a-2's compat-gate wiring lands.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #557 +/- ##
=======================================
Coverage 99.12% 99.12%
=======================================
Files 77 77
Lines 10033 10056 +23
=======================================
+ Hits 9945 9968 +23
Misses 88 88
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
Contributor
There was a problem hiding this comment.
Pull request overview
This PR wires the receiver’s bundled esphome version through the peer-link post-handshake intent_response payload, captures it on the offloader into StoredPairing.esphome_version, persists it, and surfaces it on pairing projections (so pick_build_path can later gate on version compatibility).
Changes:
- Receiver now includes
esphome_versionon every post-handshakeintent_responsepayload. - Offloader lifts
esphome_versionfrom the decrypted response, carries it onOFFLOADER_PEER_LINK_OPENED, and refreshes/persists it ontoStoredPairing(and projects it viaPairingSummary). - Adds unit + E2E coverage pinning the wire contract and the end-to-end capture/projection flow.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_remote_build_peer_link.py | Adds a unit test asserting the encrypted post-handshake response plaintext includes esphome_version. |
| tests/test_remote_build_peer_link_client.py | Adds unit tests pinning controller listener behavior for version refresh/no-op cases. |
| tests/e2e/test_pair_and_session.py | Adds E2E test asserting pairings_snapshot() surfaces receiver esphome_version after session open. |
| esphome_device_builder/models/remote_build.py | Adds esphome_version to OffloaderPeerLinkOpenedData, StoredPairing, PairingSummary, and pairing validation schema. |
| esphome_device_builder/controllers/remote_build/peer_link.py | Adds esphome_version to the receiver _send_response payload. |
| esphome_device_builder/controllers/remote_build/peer_link_client.py | Lifts esphome_version from response and threads it into the OPENED event. |
| esphome_device_builder/controllers/remote_build/controller.py | Projects StoredPairing.esphome_version and refreshes it on OPENED events with a debounced save. |
Codecov flagged the inline ``if not isinstance(...): receiver_version = ""`` arm inside ``_run_one_session`` as uncovered: that path only fires on a malformed receiver response (non-string esphome_version value), and driving it via the real Noise round-trip would mean monkeypatching ``_send_response`` to inject a non-string, which is brittle. Hoist the lift logic into a tiny module-level helper _extract_receiver_esphome_version(response: dict[str, Any]) -> str so the defensive branch is unit-testable directly with synthesised response dicts. The runtime behaviour is byte-identical to the inline form: a valid string flows through unchanged, missing field returns "", non-string value returns "". New parametrized test pins all five branches: * valid string -> string * missing field (older receiver) -> "" * non-string (int / None / dict) -> "" No behaviour change to the wire shape or the listener wiring.
* Cap peer-controlled esphome_version at the wire seam. _extract_receiver_esphome_version now rejects strings exceeding PAIRING_VERSION_MAX_LEN by returning "". Without the cap, a malicious / buggy receiver could send a string that flows through __post_init__'s validator at construction (the validator only runs at construction), get assigned to pairing.esphome_version after-the-fact, persist to disk, then poison the next sidecar load when from_dict re-runs the validator and rejects the row. * Mirror the cap on the controller-side listener. The wire seam already filters before firing OPENED; the listener gate is defense-in-depth for any other future fire site of the same event (test fixtures, future event refires). * Hoist the cap constant to PAIRING_VERSION_MAX_LEN on models/remote_build.py so the wire seam, the listener, and the validator all reference one source -- drift between them would re-open the poison vector this PR fixes. * Reword the StoredPairing.esphome_version comment: "tolerates missing fields with non-empty defaults" implied the default must be non-empty when in fact the empty-string default is what fills in. Reworded to "with declared dataclass defaults; the empty string default below is what fills in". Two new parametrized cases in test_extract_receiver_esphome_version_branches pin the at-cap (passes through) and one-past-cap (rejected to empty) branches. The listener test gains a fourth scenario asserting an oversize wire value doesn't survive into pairing.esphome_version or trigger a save.
This was referenced May 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this implement/fix?
Unblocks the deferred version-compat gate in
pick_build_path(7a-1, #553). The receiver's bundled esphome version is now advertised on every peer-linkintent_responseand captured ontoStoredPairing.esphome_versionso the scheduler can read it when deciding LOCAL vs REMOTE.From the
build_scheduler.pymodule docstring on the deferred work:This PR is the separate wiring. The gate itself stays deferred to the 7a-2 follow-up.
Wire change
Receiver's
_send_responsenow ships{intent_response, esphome_version}instead of just{intent_response}. The version comes fromesphome.const.__version__so it tracks whatever esphome the receiver imported. Applies to every intent (preview/pair_request/pair_status/peer_link) — same shared field on every response so a caller for any flow gets the receiver's version alongside the discriminator.Offloader-side capture (long-lived
peer_linkpath)_run_one_sessionliftsesphome_versionoff the decoded response. Older receivers that don't carry the field land as empty string via theisinstanceguard._fire_openedcarries the version ontoOFFLOADER_PEER_LINK_OPENED; the event's TypedDict gains the field._on_offloader_peer_link_openedlistener updates the matchingStoredPairingand schedules a debounced disk write. Same-version reconnect short-circuits. Empty version (older receiver) leaves the stored value alone — clobbering with empty during a mixed-version rollout would lose the previously captured version.Persistence
StoredPairing.esphome_version: str = ""with a max-64 validator. Empty default keeps old sidecars loading cleanly through mashumaro's missing-field handling.PairingSummary.esphome_version: str = ""mirrors the stored shape onto the wire projection. The_pairing_summary_forprojector threads the field through; every WS surface (pairings_snapshot,request_pairresponse,pair-statusevent) now carries the captured version for free.Tests
test_send_response_advertises_esphome_version— reads back the plaintext body offsession.encrypt's call args and asserts the JSON dict carries both fields. Wire-shape pin.test_peer_link_opened_refreshes_stored_pairing_version— exercises the listener directly with synthesised event payloads. Pins update / no-op-on-same-version / no-op-on-empty / receiver-upgrade branches.test_peer_link_opened_for_unknown_pin_is_silent_no_op— defense-in-depth gate for an OPENED firing after the matching row was removed.test_paired_instances_snapshot_carries_receiver_esphome_version— drives the full wire-and-capture chain through a real Noise XX handshake. Both halves run the sameesphomepackage (single process), so the value the offloader observes on its snapshot equals the version the receiver imported. Pins the contract end-to-end so a regression on the wire-shape, the lift, the listener wiring, or the projection trips this test rather than producing a silent empty value on pick_build_path's gate input.What's NOT in this PR
pick_build_pathitself doesn't readesphome_versionyet — that's the 7a-2 follow-up that adds the actual compat gate. This PR just makes the value available.PairingSummary.esphome_versionflows over the existing wire shape and frontend consumers can add a "Last known: X.Y.Z" line whenever it's useful.esphome_versionon their TXT record, and that's where it lands onRemoteBuildPeer.esphome_versionfor the discovered-hosts list. The handshake path is what populatesStoredPairing.esphome_version; the two are sibling caches.Related issue or feature (if applicable):
Types of changes
bugfixnew-featureenhancementbreaking-changerefactordocsmaintenancecidependenciesFrontend coordination
The wire projection on
PairingSummarygains a new field, but the frontend already tolerates additive shape changes — old clients ignore the unknown key. No companion PR required for this change to land cleanly. A future PR that surfaces "Last known esphome version: X.Y.Z" in the paired-receivers UI would be a small frontend addition; not required here.Checklist
ruff,codespell, yaml/json/python checks).tests/where applicable.components.jsonhas not been hand-edited (regenerate viascript/sync_components.pyif a sync is needed).docs/ARCHITECTURE.mdand/ordocs/API.md.