Skip to content

Advertise receiver esphome_version through the peer-link handshake (7a-2 prerequisite)#557

Merged
bdraco merged 3 commits into
mainfrom
receiver-version-advertise
May 10, 2026
Merged

Advertise receiver esphome_version through the peer-link handshake (7a-2 prerequisite)#557
bdraco merged 3 commits into
mainfrom
receiver-version-advertise

Conversation

@bdraco
Copy link
Copy Markdown
Member

@bdraco bdraco commented May 10, 2026

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-link intent_response and captured onto StoredPairing.esphome_version so the scheduler can read it when deciding LOCAL vs REMOTE.

From the build_scheduler.py module docstring on the deferred work:

Version-compat check. StoredPairing doesn't currently carry the receiver's esphome_version (it's available in mDNS TXT for discovered peers but doesn't flow through the pair-time handshake into the persisted row). The design doc's version_compatible gate is documented inline as the version-cache home; needs separate wiring before it can fire. Until then every connected + idle APPROVED pairing is a candidate regardless of receiver version.

This PR is the separate wiring. The gate itself stays deferred to the 7a-2 follow-up.

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; every WS surface (pairings_snapshot, request_pair response, pair-status event) now carries the captured version for free.

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. Wire-shape pin.
  • Unit 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.
  • Unit 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.
  • E2E 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 same esphome package (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_path itself doesn't read esphome_version yet — that's the 7a-2 follow-up that adds the actual compat gate. This PR just makes the value available.
  • Frontend changes — no UI surface for the version yet. PairingSummary.esphome_version flows over the existing wire shape and frontend consumers can add a "Last known: X.Y.Z" line whenever it's useful.
  • mDNS TXT path — discovered peers still carry esphome_version on their TXT record, and that's where it lands on RemoteBuildPeer.esphome_version for the discovered-hosts list. The handshake path is what populates StoredPairing.esphome_version; the two are sibling caches.

Related issue or feature (if applicable):

Types of changes

  • Bugfix (non-breaking change which fixes an issue) — bugfix
  • New feature (non-breaking change which adds functionality) — new-feature
  • Enhancement to an existing feature — enhancement
  • Breaking change (fix or feature that would cause existing functionality to not work as expected) — breaking-change
  • Refactor (no behaviour change) — refactor
  • Documentation only — docs
  • Maintenance / chore — maintenance
  • CI / workflow change — ci
  • Dependencies bump — dependencies

Frontend coordination

The wire projection on PairingSummary gains 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.

  • No frontend change needed
  • Companion frontend PR: esphome/device-builder-dashboard-frontend#

Checklist

  • The code change is tested and works locally.
  • Pre-commit hooks pass (ruff, codespell, yaml/json/python checks).
  • Tests have been added or updated under tests/ where applicable.
  • components.json has not been hand-edited (regenerate via script/sync_components.py if a sync is needed).
  • Architecture-level changes are reflected in docs/ARCHITECTURE.md and/or docs/API.md.

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.
Copilot AI review requested due to automatic review settings May 10, 2026 23:14
@github-actions github-actions Bot added the enhancement Improvement to an existing feature label May 10, 2026
@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 10, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.12%. Comparing base (4650924) to head (3797f78).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##             main     #557   +/-   ##
=======================================
  Coverage   99.12%   99.12%           
=======================================
  Files          77       77           
  Lines       10033    10056   +23     
=======================================
+ Hits         9945     9968   +23     
  Misses         88       88           
Flag Coverage Δ
py3.12 99.09% <100.00%> (+<0.01%) ⬆️
py3.14 99.12% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...ice_builder/controllers/remote_build/controller.py 99.71% <100.00%> (+<0.01%) ⬆️
...vice_builder/controllers/remote_build/peer_link.py 100.00% <100.00%> (ø)
...ilder/controllers/remote_build/peer_link_client.py 99.10% <100.00%> (+0.01%) ⬆️
esphome_device_builder/models/remote_build.py 98.96% <100.00%> (+0.01%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented May 10, 2026

Merging this PR will not alter performance

✅ 12 untouched benchmarks


Comparing receiver-version-advertise (3797f78) with main (ffb8274)

Open in CodSpeed

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_version on every post-handshake intent_response payload.
  • Offloader lifts esphome_version from the decrypted response, carries it on OFFLOADER_PEER_LINK_OPENED, and refreshes/persists it onto StoredPairing (and projects it via PairingSummary).
  • 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.

Comment thread esphome_device_builder/controllers/remote_build/peer_link_client.py Outdated
Comment thread esphome_device_builder/controllers/remote_build/controller.py
Comment thread esphome_device_builder/models/remote_build.py Outdated
bdraco added 2 commits May 10, 2026 18:19
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Improvement to an existing feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants