Skip to content

Add edit_pairing_endpoint WS command (8b: user-driven manual rebind)#548

Merged
bdraco merged 5 commits into
mainfrom
edit-pairing-endpoint-8b
May 10, 2026
Merged

Add edit_pairing_endpoint WS command (8b: user-driven manual rebind)#548
bdraco merged 5 commits into
mainfrom
edit-pairing-endpoint-8b

Conversation

@bdraco
Copy link
Copy Markdown
Member

@bdraco bdraco commented May 10, 2026

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:

  • 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 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_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. 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_ARGS for bad pin / hostname / port shape.
    • NOT_FOUND for unknown pin or pairing replaced mid-probe.
    • PRECONDITION_FAILED for non-APPROVED status, no-op edit,
      missing offloader identity, or pin mismatch.
    • UNAVAILABLE for probe transport / handshake failure.

Refactor: shared probe + commit primitives

The auto-rebind path (_probe_and_rebind_endpoint) was about
to 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
    _RebindProbeResult with one of OK / UNREACHABLE /
    PIN_MISMATCH / PAIRING_REPLACED / STATUS_CHANGED.
    Caller-agnostic — no logging, no mutation, no event firing.
  • _commit_endpoint_rebind(pairing, hostname, port) — mutate
    the row's coords, schedule the debounced save, run
    _respawn_peer_link_at_new_endpoint (cancel + respawn the
    peer-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:

Outcome Auto-rebind User-driven
OK mutate + epilogue, log info mutate + epilogue, return summary
UNREACHABLE debug log, cooldown stays raise UNAVAILABLE
PIN_MISMATCH warning log, cooldown stays raise PRECONDITION_FAILED
PAIRING_REPLACED silent skip, cooldown stays raise NOT_FOUND
STATUS_CHANGED silent skip, cooldown stays raise PRECONDITION_FAILED

Existing 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
CommandError branch:

  • match → mutates pairing + fires OFFLOADER_PAIR_ENDPOINT_REBOUND
  • pin mismatch → PRECONDITION_FAILED, pairing untouched
  • unreachable → UNAVAILABLE, pairing untouched
  • unknown pin → NOT_FOUND
  • PENDING pairing → PRECONDITION_FAILED
  • same endpoint as current → PRECONDITION_FAILED
  • pairing replaced mid-probe → NOT_FOUND, fresh pairing untouched
  • no offloader peer-link priv → PRECONDITION_FAILED

What's NOT in this PR

  • Frontend — the Send-builds row "Edit hostname" pencil +
    dialog. Lands in a follow-up frontend PR; the wire shape this
    PR pins is the contract that work consumes.
  • 8a re-auth wizard — the pin-mismatch route surfaces a
    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

  • 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

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.

  • No frontend change needed
  • Companion frontend PR: esphome/device-builder-frontend#<number> (follow-up)

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.

…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.
Copilot AI review requested due to automatic review settings May 10, 2026 21:17
@github-actions github-actions Bot added the new-feature New feature label May 10, 2026
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented May 10, 2026

Merging this PR will not alter performance

✅ 12 untouched benchmarks


Comparing edit-pairing-endpoint-8b (8efb3f9) with main (3a6e8a1)

Open in CodSpeed

@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.08%. Comparing base (3a6e8a1) to head (8efb3f9).

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##             main     #548   +/-   ##
=======================================
  Coverage   99.07%   99.08%           
=======================================
  Files          74       74           
  Lines        9536     9583   +47     
=======================================
+ Hits         9448     9495   +47     
  Misses         88       88           
Flag Coverage Δ
py3.12 99.05% <100.00%> (+<0.01%) ⬆️
py3.14 99.08% <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.68% <100.00%> (+0.01%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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

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 via peer_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.

Comment thread esphome_device_builder/controllers/remote_build/controller.py Outdated
Comment thread esphome_device_builder/controllers/remote_build/controller.py Outdated
Comment thread esphome_device_builder/controllers/remote_build/controller.py
Comment thread tests/test_remote_build_controller.py Outdated
* _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.
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

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

Comment thread esphome_device_builder/controllers/remote_build/controller.py
Comment thread esphome_device_builder/controllers/remote_build/controller.py
Comment thread docs/API.md
…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).
@bdraco bdraco merged commit 1ddb2f2 into main May 10, 2026
13 checks passed
@bdraco bdraco deleted the edit-pairing-endpoint-8b branch May 10, 2026 21:35
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new-feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants