Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ Connections that arrive on the trusted ingress site (HA add-on supervisor proxy)
| `devices/update` | `{configuration, friendly_name?, comment?, board_id?}` | `UpdateDeviceResponse` | Update device metadata (sidecar JSON) |
| `devices/set_labels` | `{configuration, label_ids: string[]}` | `Device` | Replace this device's label assignments. Pass `[]` to clear. Unknown ids return `INVALID_ARGS`. Fires `device_updated` after the scanner reload when the assignments changed the row. |
| `devices/set_labels_bulk` | `{updates: [{configuration, label_ids: string[]}, ...]}` | `[{configuration, success, error?}]` | Replace label assignments across multiple devices. One result per entry preserving input order (duplicates in `updates` produce duplicate rows). Per-entry failures (unknown id, missing device, malformed row) don't block the rest. Rows whose `configuration` can't be extracted (non-dict row, non-string value) surface as `{configuration: "", success: false, error}` so the frontend dialog can identify malformed entries from its own request payload. |
| `devices/rename` | `{configuration, new_name, config_only?: bool}` | `{configuration, job, tail_job}` (config-only / in-place: `{configuration, job: null}`) | Rename device. The online path writes the renamed YAML up-front and queues a **two-job chain**: `job` is the `COMPILE` of the new YAML (remote-eligible via the build scheduler, exactly like an install's) and `tail_job` is the dependent `RENAME` that OTA-flashes the *old* device address on the upload lane and swaps the files on success. A failed / cancelled chain deletes the new YAML so the old device is untouched. `config_only: true` (offline) and in-place renames rewrite the YAML with no jobs. A non-retargetable `esphome.name` (from packages / `!include` / an embedded substitution) refuses with `INVALID_ARGS` on every path. A config-only or in-place rename changes nothing and answers `PRECONDITION_FAILED` when the file changed while the rewrite was being validated, `NOT_FOUND` when it was deleted meanwhile, and the usual name-exists `INVALID_ARGS` when another config took the new filename meanwhile. |
| `devices/rename` | `{configuration, new_name, config_only?: bool}` | `{configuration, job, tail_job}` (config-only / in-place: `{configuration, job: null}`) | Rename device. The online path writes the renamed YAML up-front and queues a **two-job chain**: `job` is the `COMPILE` of the new YAML (remote-eligible via the build scheduler, exactly like an install's) and `tail_job` is the dependent `RENAME` that OTA-flashes the *old* device address on the upload lane and swaps the files on success. A failed / cancelled chain deletes the new YAML so the old device is untouched. `config_only: true` (offline) and in-place renames rewrite the YAML with no jobs. A non-retargetable `esphome.name` (from packages / `!include` / an embedded substitution) refuses with `INVALID_ARGS` on every path. Both remember the pre-rename hostname, which the firmware keeps broadcasting until the next install, so a later install still reaches the device (see ARCHITECTURE for the lifecycle). A config-only or in-place rename changes nothing and answers `PRECONDITION_FAILED` when the file changed while the rewrite was being validated, `NOT_FOUND` when it was deleted meanwhile, and the usual name-exists `INVALID_ARGS` when another config took the new filename meanwhile. |
| `devices/delete` | `{configuration}` | — | Delete device and associated files. A missing YAML surfaces `not_found` (same as `devices/archive`), not `internal_error`. |
| `devices/delete_bulk` | `{configurations: string[]}` | `[{configuration, success, error?}]` | Delete multiple devices |
| `devices/archive` | `{configuration}` | — | Soft-delete: move YAML to `<config_dir>/archive/`, wipe build dir, wipe StorageJSON + device-metadata sidecars. Reversible via `devices/unarchive` (cached IP/version/hash refill from the next mDNS broadcast). |
Expand Down
17 changes: 14 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,12 @@ firmware/install {configuration} → QUEUED → RUNNING → output... → COMPLE
retry's supersede → write against the superseded chain's revert unlink;
cancelling the tail cascades *up* to its compile. A persisted RENAME
with no `depends_on` (pre-decomposition) still runs the fused
`esphome rename` CLI on the compile lane.
`esphome rename` CLI on the compile lane. The flash-free branches
(`config_only` for an offline device, an in-place rename whose target
filename is the device's own, and a hand-edited `esphome.name`) skip the
chain, so the firmware keeps broadcasting the pre-rename hostname; that
name is remembered as `deployed_name` (#2730), covered below with the rest
of the live-state fields.
- Plus a **remote build-server pool** — one more consumer (`run_dispatch_loop`)
gathered alongside the lane workers. Compiles eligible for a paired server
hold here (off the single compile lane) and run concurrently, one per
Expand Down Expand Up @@ -803,7 +808,7 @@ The dashboard writes a small set of files into `<config_dir>` and `<data_dir>` a
|---|---|---|---|
| `.device-builder.json` | `<config_dir>` | Cross-flavor shared identity + per-device identity (`dashboard_id`, `_remote_build.enabled`, `_labels`; per-device `board_id` / `friendly_name` / `comment` / `labels` / `mac_address`). Shared across HA-addon flavors that mount the same `/config/esphome` tree. | umask default |
| `.device-builder.json.corrupt`(`.<ns>`) | `<config_dir>` | Quarantined copies of an unparsable `.device-builder.json`, side-renamed by `metadata_transaction` before its write-back so the corrupt bytes stay recoverable. Original `.corrupt` is never overwritten; repeat incidents land at timestamped siblings pruned oldest-first to a cap of 3. Same sensitivity as the live sidecar. | inherits the sidecar's mode |
| `.device-builder-devices.json` | `<data_dir>` | Per-flavor live device state (`ip`, `expected_config_hash`, `deployed_config_hash`, `deployed_version`, `api_encryption_active`, `build_size_*`, `regen_failed_*`). Owned by `helpers.storage.Store` with debounced writes (2s coalesce); flushed on shutdown via the controller's `_shutdown_callbacks` list. | 0o600 enforced at write time (default for `Store`) |
| `.device-builder-devices.json` | `<data_dir>` | Per-flavor live device state (`ip`, `expected_config_hash`, `deployed_config_hash`, `deployed_version`, `deployed_name`, `api_encryption_active`, `build_size_*`, `regen_failed_*`). Owned by `helpers.storage.Store` with debounced writes (2s coalesce); flushed on shutdown via the controller's `_shutdown_callbacks` list. | 0o600 enforced at write time (default for `Store`) |
| `.receiver_peers.json` | `<config_dir>` | Receiver-side pinned offloaders (`StoredPeer` rows: `(dashboard_id, pin_sha256, static_x25519_pub, label, paired_at, peer_ip, friendly_name, ha_addon, label_auto)`). Owned by `helpers.storage.Store` with debounced writes; only APPROVED rows ever reach disk (PENDING lives in `_pending_peers` and is bounded by the pairing window). A reader can enumerate which `dashboard_id`s have paired with this receiver, but neither pin nor pubkey is secret on its own. | 0o600 enforced at write time (default for `Store`) |
| `.offloader_pairings.json` | `<config_dir>` | Offloader-side pinned receivers (`StoredPairing` rows: `(receiver_hostname, receiver_port, pin_sha256, static_x25519_pub, label, paired_at, status, esphome_version, enabled, auto_provision_supported, friendly_name, ha_addon, reset_build_env_supported, receiver_label_auto)`). Owned by `helpers.storage.Store` with debounced writes; only APPROVED rows ever reach disk (PENDING is filtered out at serialise time). Same secret-equivalent shape as the receiver's `.receiver_peers.json`: a reader can enumerate which receivers this offloader has paired with, but neither pin nor pubkey is secret on its own. | 0o600 enforced at write time (default for `Store`) |
| `.device-builder-pending-keys.json` | `<data_dir>` | **Plaintext HA-provisioned Noise API keys awaiting adoption. Sensitive.** Name-keyed `{key, mac}` entries received over the ingress-only `POST /encryption-key`; consumed by `devices/import` and by later pushes once configured. A reader can connect to those devices' native APIs. Entries never expire by design — a stale entry is a cheaper failure than losing the only recovery copy of a key. | 0o600 enforced at write time (default for `Store`) |
Expand All @@ -816,7 +821,13 @@ The dashboard writes a small set of files into `<config_dir>` and `<data_dir>` a
Per-device metadata is partitioned across two files by *who writes it* and *how often*:

* **Identity** (`board_id`, `friendly_name`, `comment`, `labels`, `mac_address`) lives in `<config_dir>/.device-builder.json` alongside the cross-flavor catalog keys (`_labels`, `_remote_build`, `dashboard_id`). Access goes through `SharedSidecarClient` — a thin async wrapper around the existing `helpers/metadata_sidecar.metadata_transaction` (`fcntl.flock` + `_METADATA_LOCK` for cross-flavor RMW safety). Writes are infrequent (user-edited names, scanner-derived `board_id` backfill, first-observation `mac_address`) and run through the transactional path so the `esphome` / `esphome-beta` / `esphome-dev` flavors on a shared `/config/esphome` can't clobber each other.
* **Live state** (`ip`, `expected_config_hash`, `deployed_config_hash`, `deployed_version`, `api_encryption_active`, `build_size_*`, `regen_failed_*`) lives in `<data_dir>/.device-builder-devices.json`. Access goes through `DeviceMetadataStore` — a `helpers.storage.Store`-backed RAM-canonical dict that debounces writes (2s coalesce) and flushes on shutdown. The store keys on `<data_dir>` rather than `<config_dir>` because each HA-addon flavor compiles its own binaries and observes its own mDNS broadcasts; sharing this state across flavors would let one flavor's running-firmware hash overwrite another's. The file is per-flavor by construction, so no cross-process lock is needed beyond the single-instance startup `flock` that already pins one process per `data_dir`.
* **Live state** (`ip`, `expected_config_hash`, `deployed_config_hash`, `deployed_version`, `deployed_name`, `api_encryption_active`, `build_size_*`, `regen_failed_*`) lives in `<data_dir>/.device-builder-devices.json`. Access goes through `DeviceMetadataStore` — a `helpers.storage.Store`-backed RAM-canonical dict that debounces writes (2s coalesce) and flushes on shutdown. The store keys on `<data_dir>` rather than `<config_dir>` because each HA-addon flavor compiles its own binaries and observes its own mDNS broadcasts; sharing this state across flavors would let one flavor's running-firmware hash overwrite another's. The file is per-flavor by construction, so no cross-process lock is needed beyond the single-instance startup `flock` that already pins one process per `data_dir`.

`deployed_name` is the odd one out: it is stamped, not observed. A rename that doesn't flash (config-only, in-place, or a hand-edited `esphome.name` on a device with build output) leaves the firmware answering its old hostname, so that name is recorded and backs the device's OTA address-cache args — published under the new `<name>.local` key, so an install still reaches a device that never announces its new name. Both readers (the cache-args build and the API reviver, which would otherwise read the firmware's pre-rename name as a re-leased IP and wipe the persisted address) ignore it while another config owns that name, since the answer is then somebody else's. It clears on:

* the next app flash (a `--bootloader` upload replaces no app, so the record stands) or a completed rename chain;
* a rename back to the recorded name;
* mDNS taking ownership of the device's own name, when that name maps to one config — the self-heal for a flash from outside the dashboard.

The `STORE_FIELDS` frozenset in `controllers/devices/_metadata_store.py` enumerates the live-state field names; `DeviceMetadataBase._persist_device_metadata_async` is the routing dispatcher (anything in `STORE_FIELDS` → store, everything else → shared sidecar). The mDNS hot path (`state_callbacks.on_*`) writes the store directly via `controller._metadata_store.update(...)` / `set_field(...)` — sync RAM mutation on the event loop, debounced disk write on the executor.

Expand Down
3 changes: 3 additions & 0 deletions esphome_device_builder/controllers/_device_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ class DeviceFileMetadata(NamedTuple):
# absent (plaintext-confirmed); ``None`` means not yet
# broadcast.
api_encryption_active: str | None = None
# Pre-rename hostname the firmware still answers to, if any.
deployed_name: str = ""


class ScanChange(StrEnum):
Expand Down Expand Up @@ -496,6 +498,7 @@ def _load_devices(self, paths: set[Path], *, shallow: bool = False) -> dict[Path
deployed_version=metadata.deployed_version,
queued_update=metadata.queued_update,
api_encryption_active=metadata.api_encryption_active,
deployed_name=metadata.deployed_name,
previous=self._index.by_path.get(path),
shallow=shallow,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,11 @@ async def _verify_and_revive(self, device: Device, ip: str, rtt: float) -> None:
)
self._record_dial_failure(key)
return
if reported != device.name:
# The recorded pre-rename name is identity too, unless another config owns it.
recorded = device.deployed_name
if recorded and monitor._get_devices_by_name(recorded):
recorded = ""
if reported not in (device.name, recorded):
# Whatever holds the lease now is a different device; the
# persisted IP is proven stale — invalidate it so neither the
# reviver nor the OTA cache trusts it again. ``reported`` is
Expand Down
8 changes: 8 additions & 0 deletions esphome_device_builder/controllers/devices/_metadata_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,16 @@

_DEFAULT_SAVE_DELAY = 2.0

# Read-only miss sentinel for ``get_field``; never mutated.
_EMPTY: dict[str, Any] = {}

# Fields the store owns. Everything else lives in the shared sidecar.
STORE_FIELDS: frozenset[str] = frozenset(
{
"ip",
"deployed_config_hash",
"deployed_version",
"deployed_name",
"queued_update",
"api_encryption_active",
"expected_config_hash",
Expand Down Expand Up @@ -109,6 +113,10 @@ def get(self, filename: str) -> dict[str, Any]:
"""Return a shallow copy of *filename*'s metadata."""
return dict(self._state.get(filename, {}))

def get_field(self, filename: str, key: str) -> Any:
"""Return one field of *filename*'s metadata without copying the entry."""
return self._state.get(filename, _EMPTY).get(key)

def update(
self,
filename: str,
Expand Down
2 changes: 1 addition & 1 deletion esphome_device_builder/controllers/devices/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ def get_address_cache_args(self, configuration: str) -> list[str]:
loaded = device.loaded_integrations
if loaded and "api" not in loaded and "web_server" not in loaded:
return []
return _build_address_cache_args(device, self._state_monitor)
return _build_address_cache_args(device, self._state_monitor, self._deployed_name(device))

def get_ota_address_cache_args(self, configuration: str, port: str | None) -> list[str]:
"""Return cache args when ``port == "OTA"`` (or ``None`` for always-OTA flows)."""
Expand Down
8 changes: 8 additions & 0 deletions esphome_device_builder/controllers/devices/firmware_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,12 @@ def on_job_completed(controller: DevicesController, event: Event[JobLifecycleDat
return
recompute_hash = job_type in COMPILING_JOB_TYPES
flashed = job_type in (JobType.UPLOAD, JobType.INSTALL)
bootloader_only = job_type is JobType.UPLOAD and job.flash_bootloader
if flashed and not bootloader_only:
# A bootloader-only upload replaces no app, so its record stands.
# Cleared here, not in the background refresh, so a following job's
# address-cache read can't race it.
controller._clear_deployed_name(configuration)
# Routed through the controller's bound delegate so tests
# that monkeypatch ``_refresh_after_firmware_job`` on the
# instance still intercept.
Expand Down Expand Up @@ -233,6 +239,8 @@ async def migrate_metadata_then_scan(
controller: DevicesController, old_configuration: str, new_configuration: str
) -> None:
"""Move the renamed device's metadata before the scan rebuilds it."""
# The tail flashed the new name; a completed RENAME reaches no other clear.
controller._clear_deployed_name(old_configuration)
await migrate_metadata(controller, old_configuration, new_configuration)
await rescan_renamed(controller, new_configuration)

Expand Down
18 changes: 15 additions & 3 deletions esphome_device_builder/controllers/devices/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from ...helpers.async_ import run_in_executor
from ...helpers.atomic_io import atomic_write_exclusive
from ...helpers.device_config import raise_device_not_found
from ...helpers.hostname import is_local_hostname, normalize_hostname
from ...helpers.hostname import default_mdns_address, is_local_hostname, normalize_hostname
from ...helpers.text import diff_excerpt, same_text
from ...helpers.yaml import read_yaml_scalar, rewrite_name_or_substitution
from ...models import ConfigEntryType, Device, ErrorCode
Expand Down Expand Up @@ -476,8 +476,10 @@ def _gates_on_unconfigured_block(
return bool(gate) and gate not in configured_blocks


def _build_address_cache_args(device: Device, monitor: DeviceStateMonitor | None) -> list[str]:
"""Build CLI cache args from the IPs we already have for *device*."""
def _build_address_cache_args(
device: Device, monitor: DeviceStateMonitor | None, deployed_name: str = ""
) -> list[str]:
"""Build CLI cache args from the IPs we have for *device* or *deployed_name*."""
address = device.address
if not address:
return []
Expand All @@ -502,6 +504,16 @@ def _build_address_cache_args(device: Device, monitor: DeviceStateMonitor | None
if is_local
else monitor.state.dns_cache.get_cached_addresses(address)
)
if not cached and is_local and deployed_name:
# Publish the pre-rename name's IPs under the key the CLI looks up.
cached = monitor.mdns.get_cached_addresses(default_mdns_address(deployed_name))
if cached:
_LOGGER.info(
"OTA for %s targets %s, the hostname its firmware still answers to (%s)",
normalized,
deployed_name,
", ".join(cached),
)
if cached:
addresses = list(cached)

Expand Down
Loading
Loading