Add edit_pairing_endpoint WS command (8b: user-driven manual rebind)#548
Merged
Conversation
…l rebind) Frontend-only fallback for the cross-subnet / no-mDNS cases the 4a-o part 7 auto-rebind can't catch: cross-subnet receivers (no mDNS path), mDNS disabled on the receiver's host, the receiver moved to a hostname the offloader's network can resolve but mDNS doesn't broadcast. The Send-builds row will get an inline 'Edit hostname' pencil; this PR is the backend WS command + the shared primitive the auto-rebind already needed factored. * New remote_build/edit_pairing_endpoint(pin_sha256, hostname, port) WS command. Validates inputs, looks up the existing APPROVED pairing, runs a one-shot peer_link_preview_pair probe at the candidate endpoint, and on a matching pin mutates StoredPairing.receiver_hostname / .receiver_port in place + cancels + respawns the PeerLinkClient against the new coords. Same trust model as the auto-rebind: pin mismatch refuses the edit (the user's existing trust is keyed on the original pin; substituting a fresh pubkey is the case 8a's re-auth wizard exists to gate). UNAVAILABLE on probe transport failure; PRECONDITION_FAILED on non-APPROVED, no-op edit, missing offloader identity, or pin mismatch; NOT_FOUND on unknown pin or pairing replaced mid-probe. * Refactor the auto-rebind path. Two new shared primitives — _probe_pairing_endpoint (returns a typed _RebindProbeResult with one of OK / UNREACHABLE / PIN_MISMATCH / PAIRING_REPLACED / STATUS_CHANGED) and _commit_endpoint_rebind (mutate + schedule save + respawn epilogue) — share the probe + commit shape between auto- and user-driven rebinds. Each caller maps the typed outcome onto its own surface (silent log + cooldown for auto, typed CommandError for the WS command). Existing auto-rebind tests still pass. * Eight new tests for the WS command pinning every CommandError branch (match / pin-mismatch / unreachable / unknown-pin / pending / same-endpoint / replaced-mid-probe / no-priv). * docs/API.md: row added under the offloader-side pair flow.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #548 +/- ##
=======================================
Coverage 99.07% 99.08%
=======================================
Files 74 74
Lines 9536 9583 +47
=======================================
+ Hits 9448 9495 +47
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
Adds a new offloader-side WebSocket command to manually rebind an existing APPROVED remote-build pairing to a new (hostname, port) when mDNS auto-rebind can’t discover the new endpoint. The implementation factors the existing auto-rebind probe logic into shared “probe” + “commit” primitives so both the mDNS-driven and user-driven flows share the same identity verification and epilogue.
Changes:
- Add WS command
remote_build/edit_pairing_endpoint(pin_sha256, hostname, port)to validate inputs, probe the candidate endpoint viapeer_link_preview_pair, and (on matching pin) persist + respawn the peer-link client. - Refactor auto-rebind to reuse shared
_probe_pairing_endpoint(...)and_commit_endpoint_rebind(...)helpers. - Add comprehensive controller tests for the new WS command’s error mapping and success path; document the new command in
docs/API.md.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
esphome_device_builder/controllers/remote_build/controller.py |
Introduces shared probe/commit helpers and the new remote_build/edit_pairing_endpoint WS command; refactors auto-rebind to use the shared primitives. |
tests/test_remote_build_controller.py |
Adds 8 async tests covering success + all CommandError branches for manual endpoint editing. |
docs/API.md |
Documents the new WS command and its behavior/error mapping. |
* _commit_endpoint_rebind now pops the per-pin auto-probe
cooldown so a successful user-driven rebind also clears
any stale cooldown a previous failed mDNS-driven probe may
have seeded. Without this, a manual rebind to a working
endpoint left the cooldown in place and a subsequent mDNS
Updated for the same pin would be blocked by the stale
entry until expiry. Auto path's separate _rebind_probe_until.pop
call drops since it's now in the shared epilogue.
* Reorder edit_pairing_endpoint's pre-checks: system-readiness
(offloader_peer_link_priv loaded) before user-input
semantics (no-op edit). A user hitting Save with unchanged
coords during startup now gets the distinct 'identity not
loaded yet' message rather than the misleading 'matches
current'.
* Rename test_edit_pairing_endpoint_skips_when_pairing_replaced_mid_probe
-> ..._raises_not_found_when_pairing_replaced_mid_probe.
Only the auto path 'skips silently'; the user path raises.
* Drop the redundant lowercase clause from the PENDING-status
assertion. The .upper() comparison already subsumes case.
* Two new tests:
* test_edit_pairing_endpoint_status_changed_mid_probe_raises_precondition_failed
closes the codecov gap on the STATUS_CHANGED outcome
branch (lines 2925-2926).
* test_edit_pairing_endpoint_invalid_args_rejected_before_lookup
parametrised across nine bad-shape inputs (pin / hostname /
port) pinning that validators run before the dict lookup.
Defends against an accidental reordering that would fall
through to the dict's __getitem__ with a tainted value
and surface as a generic 500 instead of the typed
INVALID_ARGS the frontend expects.
* _RebindProbeResult.transport_error is now PeerLinkClientError | None (was str). The auto-rebind debug log can pass it as exc_info= to preserve the traceback for diagnosing handshake / connect failures in the field — the inline except block before this path was factored had exc_info=True and that shape regressed when the exception was stringified at capture time. The user-driven path's f-string substitution still gets the same message via the exception's __str__. * _probe_pairing_endpoint docstring: qualify the enum member cross-references as :attr:`_RebindProbeOutcome.UNREACHABLE` etc. so Sphinx resolves them correctly. * edit_pairing_endpoint hostname arg docstring: was claiming normalize_hostname-folded shape but the validator (_validate_hostname) only does trim + str.lower; the trailing-dot / FQDN normalisation only happens inside _endpoints_equal at compare time. Updated to match what's actually persisted.
…atch table The four if-result.outcome-is-X-then-raise blocks all share the same shape (look up an ErrorCode + a message template keyed on the enum) and only differ in the per-outcome code + template strings. Replaced with a module-level _EDIT_PAIRING_PROBE_ERRORS table keyed on _RebindProbeOutcome and a single 'raise CommandError' at the call site that formats the template with all five available keys (host / port / pin / observed / error). Each outcome's rationale moves to the table comment so it stays declarative and the WS handler reads as 'probe → on non-OK, raise per the table'.
…gnature
The API row was stale from before 4a-o part 6 — it listed
{hostname, port} but the implementation has been keyed on
pin_sha256 since then. Predates this PR but caught while
reviewing; fix here keeps the doc consistent with the wire
contract the offloader frontend has been speaking against
the merged backend.
Also note that unpair cancels both the pair-status listener
AND the long-lived peer-link client (the latter wasn't called
out in the original wording).
Merged
13 tasks
bdraco
added a commit
to esphome/device-builder-frontend
that referenced
this pull request
May 10, 2026
Frontend mirror for backend phase 8b (esphome/device-builder#548). The Send-builds Settings section now renders a small pencil icon on every APPROVED paired-row that opens a focused dialog to update the receiver's hostname / port without re-pairing. Fallback for the cross-subnet / no-mDNS / mDNS-disabled cases the 4a-o part 7 auto-rebind can't catch. * New ESPHomeAPI.editRemoteBuildPairingEndpoint wrapper hitting remote_build/edit_pairing_endpoint. Returns the updated PairingSummary; primarily used as a 'rebind succeeded' signal — the pairings-context subscriber on app-shell upserts the row from the OFFLOADER_PAIR_ENDPOINT_REBOUND event the backend fires alongside the synchronous response. * New <esphome-edit-pairing-endpoint-dialog> component. Two inputs (hostname + port) pre-filled with the row's current coords. Save fires the WS command; light-dismiss / close-button gated while the round-trip is in flight so a dismiss can't orphan the response. Save button disabled while submitting, while inputs are invalid, and while the values are unchanged from initial (no-op edits would be rejected by the backend with PRECONDITION_FAILED anyway — catching at the dialog avoids a pointless round-trip). Hostname renders with the trailing dot stripped through trimTrailingDot to match the dashboard's display convention; backend's _validate_hostname accepts both forms. * Inline error mapping mirrors the dispatch dialog: UNAVAILABLE / PRECONDITION_FAILED / NOT_FOUND / INVALID_ARGS map to specific copy with the backend's diagnostic detail interpolated where useful (the PRECONDITION_FAILED message in particular benefits — backend folds four distinct preconditions onto that code, and the detail string distinguishes pin-mismatch from no-op / not-APPROVED / no-priv). * Settings-dialog wires the dialog tag, an _onEditPairingEndpointClick handler, and an icon-only pencil button on the pairing row. Pencil sits between View-build and Unpair so the row reads [Build remotely] [View build] [✏] [Unpair] when connected, [✏] [Unpair] otherwise. Hidden on PENDING rows since there's no peer-link client to respawn yet. * en/fr/nl translation parity, twelve new keys per locale. * New API client test pinning the wire shape and the PairingSummary return projection.
16 tasks
bdraco
added a commit
that referenced
this pull request
May 10, 2026
Two arch.md gaps accumulated across the last ~5 merges; close them before 7a-2 lands so the next architectural slice has a documented frame to refer back to. **Endpoint rebind paragraph (post Auto-reconnect).** Covers the two entry points that share `_probe_pairing_endpoint` / `_commit_endpoint_rebind`: - 4a-o part 7 automatic mDNS rebind (#539, merged). - 8b user-driven `remote_build/edit_pairing_endpoint` WS command (#548, merged). Both fire the same probe-before-mutate primitive — pin must match the new endpoint or the edit refuses (substituting a fresh pubkey under existing trust is what the 8a re-auth wizard exists to gate). **Transparent install flow sub-section (in the Remote build section, before Persisted state).** Captures the load-bearing "receiver only ever compiles; the offloader always installs" policy that issue #106 § Transparent install flow elevated to core architecture, plus the implementation slices: - 7a-1 (`BuildScheduler` decision primitive, #553 merged) — pure `pick_build_path(BuildSchedulerInputs) -> BuildPathDecision`, frozen-view input wrapper with `Mapping` / `frozenset` typing, fail-closed-by-construction `is PeerStatus.APPROVED` gate, `pin_sha256: str | None` decision shape. - 7a-2 (event-stream bridge, open) — new `FirmwareJob.source` discriminator + `OFFLOADER_JOB_*` → `JOB_*` re-fire so the install dialog renders remote builds without learning about remote event types. - 7a-3 (`firmware/install` integration, open) — WS handler routes through scheduler, dispatches `submit_job{target: "compile"}`, kicks off the 7a-2 bridge. - 7a-4 (cancel translation, open) — Stop button → `cancel_job` against the receiver when synthetic job's `source` is remote. Plus a note on the deferred per-pairing `esphome_version` wiring that the design-doc version-compat gate needs. Points to issue #106 § Transparent install flow as the source of truth for the full design. No code change.
16 tasks
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?
Phase 8b backend: user-driven manual rebind of an existing
APPROVED pairing onto new (
hostname,port) coords. Frontend-only fallback for the cases the 4a-o part 7 mDNS auto-rebind
can't catch:
resolve but mDNS doesn't broadcast.
The Send-builds drawer's per-row "Edit hostname" pencil will
land in a follow-up frontend PR; this PR is the backend WS
command plus the shared primitive the auto-rebind already
needed factored.
What ships
New WS command
remote_build/edit_pairing_endpoint(pin_sha256, hostname, port).Validates inputs, looks up the existing APPROVED pairing,
runs a one-shot
peer_link_preview_pairprobe at thecandidate endpoint, and on a matching pin mutates
StoredPairing.receiver_hostname/.receiver_portinplace + cancels + respawns the
PeerLinkClientagainst thenew coords. Returns the updated
PairingSummary.Same trust model as the auto-rebind. Pin mismatch refuses
the edit and leaves the stored pairing untouched — the user's
existing trust is keyed on the original pin; substituting a
fresh pubkey under that trust is the case 8a's re-auth wizard
exists to gate. The dialog renders the inline error; the
user can re-pair through 8a if they actually want the new
identity.
Error mapping:
INVALID_ARGSfor bad pin / hostname / port shape.NOT_FOUNDfor unknown pin or pairing replaced mid-probe.PRECONDITION_FAILEDfor non-APPROVED status, no-op edit,missing offloader identity, or pin mismatch.
UNAVAILABLEfor probe transport / handshake failure.Refactor: shared probe + commit primitives
The auto-rebind path (
_probe_and_rebind_endpoint) was aboutto grow a near-clone for the user-driven path. Instead, factored
the probe-and-verify shape into two new shared primitives:
_probe_pairing_endpoint(pairing, new_hostname, new_port) -> _RebindProbeResult— runs the preview probe, identity check, and race-safe
re-check on the dict entry. Returns a typed
_RebindProbeResultwith one ofOK/UNREACHABLE/PIN_MISMATCH/PAIRING_REPLACED/STATUS_CHANGED.Caller-agnostic — no logging, no mutation, no event firing.
_commit_endpoint_rebind(pairing, hostname, port)— mutatethe row's coords, schedule the debounced save, run
_respawn_peer_link_at_new_endpoint(cancel + respawn thepeer-link client + fire
OFFLOADER_PAIR_ENDPOINT_REBOUND).Caller is responsible for the probe + identity verify before
calling.
Each caller maps the typed outcome onto its own surface:
OKUNREACHABLEUNAVAILABLEPIN_MISMATCHPRECONDITION_FAILEDPAIRING_REPLACEDNOT_FOUNDSTATUS_CHANGEDPRECONDITION_FAILEDExisting auto-rebind tests still pass; the only behavior change
in the auto path is replacing inlined logic with the shared
primitives.
Tests
Eight new tests for the WS command pinning every
CommandErrorbranch:OFFLOADER_PAIR_ENDPOINT_REBOUNDPRECONDITION_FAILED, pairing untouchedUNAVAILABLE, pairing untouchedNOT_FOUNDPRECONDITION_FAILEDPRECONDITION_FAILEDNOT_FOUND, fresh pairing untouchedPRECONDITION_FAILEDWhat's NOT in this PR
dialog. Lands in a follow-up frontend PR; the wire shape this
PR pins is the contract that work consumes.
typed error here; the multi-step OOB-verify-and-re-pair flow
for actually accepting a new identity is 8a's scope.
Related issue or feature (if applicable):
Types of changes
bugfixnew-featureenhancementbreaking-changerefactordocsmaintenancecidependenciesFrontend coordination
Wire surface added: new WS command + new error mapping. The
frontend PR that adds the "Edit hostname" pencil will consume
this; will link the companion PR here once it's open.
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.