Skip to content

Commit fec63f0

Browse files
authored
Stop duplicating Wi-Fi secrets and attribute secrets.yaml parse errors to the user (#2624)
1 parent e1009fe commit fec63f0

13 files changed

Lines changed: 811 additions & 129 deletions

File tree

docs/API.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ Connections that arrive on the trusted ingress site (HA add-on supervisor proxy)
120120
|---------|------|----------|-------------|
121121
| `devices/list` || `DevicesResponse` | List configured + importable devices |
122122
| `devices/get_states` || `dict` | Get device online/offline states |
123-
| `devices/create` | `{name, friendly_name?, board_id?, ssid?, psk?, file_content?, overwrite?}` | `WizardResponse` | Create device. With `friendly_name`, `name` is the hostname — validated (lowercase letters, digits, hyphens, underscores; no leading/trailing hyphen; at most 31 chars; `INVALID_ARGS` otherwise) and used verbatim, never rewritten — and the cleaned `friendly_name` becomes `esphome.friendly_name:`. Without it (or when it cleans to empty), `name` is the user's raw display label — capitalisation, inter-word spaces, and unicode are preserved; surrounding whitespace is trimmed. The backend slugifies the cleaned value for `esphome.name:` and the YAML filename and writes the cleaned original into `esphome.friendly_name:`. Callers that already pass a slugified value get the same hostname/friendly_name pair as before (slug-of-slug is a no-op). Three flows: `file_content` writes the supplied YAML as-is; `board_id` generates from a board template; with neither, emits a minimal esp32 stub for the "empty configuration" path. **Wi-Fi handling:** a supplied `ssid` / `psk` is written to `secrets.yaml` (validated, shared with `config/set_wifi_credentials`) and the generated YAML references `!secret wifi_ssid` / `!secret wifi_password` — bare credentials are never written into the device YAML, and the next device reuses the shared secret. With no `ssid`, the generator emits `!secret` when Wi-Fi secrets already exist, else (for a board with no other network) a no-network stub. A board offering onboard-ethernet suggested hardware is **wired by default** — its `ethernet:` block is auto-pulled and the `wifi:` block dropped (see the network-providers note under [Boards](#boards)). A filename collision returns `ALREADY_EXISTS`; pass `overwrite: true` to replace the YAML in place, preserving the existing device's metadata (labels / comment / board_id) and StorageJSON. For a package board, `WizardResponse.warning` is set when the config was kept despite a validation failure confined to remote package resolution (same contract as `devices/import`). |
123+
| `devices/create` | `{name, friendly_name?, board_id?, ssid?, psk?, file_content?, overwrite?}` | `WizardResponse` | Create device. With `friendly_name`, `name` is the hostname — validated (lowercase letters, digits, hyphens, underscores; no leading/trailing hyphen; at most 31 chars; `INVALID_ARGS` otherwise) and used verbatim, never rewritten — and the cleaned `friendly_name` becomes `esphome.friendly_name:`. Without it (or when it cleans to empty), `name` is the user's raw display label — capitalisation, inter-word spaces, and unicode are preserved; surrounding whitespace is trimmed. The backend slugifies the cleaned value for `esphome.name:` and the YAML filename and writes the cleaned original into `esphome.friendly_name:`. Callers that already pass a slugified value get the same hostname/friendly_name pair as before (slug-of-slug is a no-op). Three flows: `file_content` writes the supplied YAML as-is; `board_id` generates from a board template; with neither, emits a minimal esp32 stub for the "empty configuration" path. **Wi-Fi handling:** a supplied `ssid` / `psk` is written to `secrets.yaml` (validated, shared with `config/set_wifi_credentials`) and the generated YAML references `!secret wifi_ssid` / `!secret wifi_password` — bare credentials are never written into the device YAML, and the next device reuses the shared secret. With no `ssid`, the generator emits `!secret` when Wi-Fi secrets already exist, else (for a board with no other network) a no-network stub. A board offering onboard-ethernet suggested hardware is **wired by default** — its `ethernet:` block is auto-pulled and the `wifi:` block dropped (see the network-providers note under [Boards](#boards)). A filename collision returns `ALREADY_EXISTS`; pass `overwrite: true` to replace the YAML in place, preserving the existing device's metadata (labels / comment / board_id) and StorageJSON. For a package board, `WizardResponse.warning` is set when the config was kept despite a validation failure confined to remote package resolution (same contract as `devices/import`). A pre-write validation failure rooted in the config dir's `secrets.yaml` (a duplicate key, a parse error) is `INVALID_ARGS` phrased `Can't <action>: secrets.yaml has a duplicate key "<key>" (lines A and B). …` or `Can't <action>: secrets.yaml doesn't parse: …` (a rewrite that can't reach the live definition says `secrets.yaml defines "<key>" where the dashboard can't rewrite it`), never the generator-bug `INTERNAL_ERROR`; the same attribution applies to every mutation that validates (`clone`, `rename`, `import`, …), and the frontend keys its "Open secrets" action on that phrasing. |
124124
| `devices/import_bundle_token` || `{token}` | Mint a single-use token for the HTTP upload of one bundle. Bundles are uploaded over HTTP, not the WebSocket (see the note below); the response's `ImportBundleResponse` shape and the `overwrite` semantics are documented there. |
125125
| `devices/update` | `{configuration, friendly_name?, comment?, board_id?}` | `UpdateDeviceResponse` | Update device metadata (sidecar JSON) |
126126
| `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. |
@@ -344,8 +344,8 @@ Backed by [`controllers/editor.py`](../esphome_device_builder/controllers/editor
344344
| `config/get_preferences` || `UserPreferences` | Get user preferences |
345345
| `config/set_preferences` | `{theme?, dashboard_view?, experience_level?, remote_compute_only?, hide_device_builder?, version_history_enabled?, device_editor_layout?, secrets_editor_layout?, ...}` | `UserPreferences` | Update preferences (partial). `experience_level` is `beginner` / `expert` (or `null` until chosen); `remote_compute_only` marks an install as a remote build node; `hide_device_builder` (offered in the UI only while that is on) hides the dashboard's Device builder section entirely. `version_history_enabled` (default `true`) gates the git auto-commit of config edits; setting it `false` stops new commits and skips repo creation, leaving any existing repo intact. `device_editor_layout` is `visual` / `yaml` / `both` and `secrets_editor_layout` is `visual` / `yaml` (the secrets editor has no split view); they persist which editor panes the user keeps open. |
346346
| `config/get_secrets` || `[string]` | List secret key names |
347-
| `config/set_secret` | `{key, value, overwrite?}` | `{created}` | Atomically set one secret in secrets.yaml under a write lock; `overwrite=false` is create-if-absent |
348-
| `config/set_wifi_credentials` | `{ssid, password?}` | `{}` | Set `wifi_ssid` / `wifi_password` in `secrets.yaml` via a line-based rewrite that preserves standalone and inline trailing comments and other secrets. Backs the kebab "Set up Wi-Fi" action; the create wizard's own Wi-Fi entry is persisted by `devices/create`. Validates (shared with that path) against ESPHome's length limits (32 char SSID, 64 char password) plus a control-character check; empty / whitespace-only SSID, oversize values, and control characters (other than `\t`) raise `INVALID_ARGS`. `password` is optional and defaults to the empty string for open networks. |
347+
| `config/set_secret` | `{key, value, overwrite?}` | `{created}` | Atomically set one secret in secrets.yaml under a write lock; `overwrite=false` is create-if-absent. A duplicated key collapses to the one rewritten top-level definition, and the save is refused with `INVALID_ARGS` (file untouched) when the result wouldn't parse or doesn't resolve the key to the new value |
348+
| `config/set_wifi_credentials` | `{ssid, password?}` | `{}` | Set `wifi_ssid` / `wifi_password` in `secrets.yaml` via a line-based rewrite that preserves standalone and inline trailing comments and other secrets. Backs the kebab "Set up Wi-Fi" action; the create wizard's own Wi-Fi entry is persisted by `devices/create`. Validates (shared with that path) against ESPHome's length limits (32 char SSID, 64 char password) plus a control-character check; empty / whitespace-only SSID, oversize values, and control characters (other than `\t`) raise `INVALID_ARGS`. `password` is optional and defaults to the empty string for open networks. The rewrite collapses a duplicated `wifi_ssid` / `wifi_password` to one definition and is refused with `INVALID_ARGS` (file untouched) when the result still wouldn't parse. |
349349

350350
### Onboarding
351351

esphome_device_builder/controllers/config/controller.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@
1616
SecretsContentError,
1717
is_valid_secret_key,
1818
read_secrets_yaml,
19-
validate_wifi_credentials,
19+
secrets_unparsable_message,
20+
set_wifi_secrets,
2021
write_secret,
21-
write_wifi_secrets,
2222
)
2323
from ...helpers.storage import ShutdownCallback, drain_shutdown_callbacks
2424
from ...helpers.storage_path import resolve_storage_path
@@ -166,7 +166,8 @@ async def set_secret(
166166
)
167167
except SecretsContentError as err:
168168
raise CommandError(
169-
ErrorCode.INVALID_ARGS, f"refusing to save invalid secrets.yaml: {err}"
169+
ErrorCode.INVALID_ARGS,
170+
secrets_unparsable_message("save the secret", err.problem),
170171
) from err
171172
return {"created": created}
172173

@@ -187,12 +188,9 @@ async def set_wifi_credentials(
187188
the next ``compile``, and preserves any other secret keys + the
188189
file's comments via a line-based rewrite.
189190
"""
190-
try:
191-
validate_wifi_credentials(ssid, password)
192-
except SecretsContentError as err:
193-
raise CommandError(ErrorCode.INVALID_ARGS, str(err)) from err
194-
config_dir = self._db.settings.config_dir
195-
await self._db.write_secrets_locked(write_wifi_secrets, config_dir, ssid, password)
191+
await set_wifi_secrets(
192+
self._db.write_secrets_locked, self._db.settings.config_dir, ssid, password
193+
)
196194
return {}
197195

198196
@api_command("config/get_info")

esphome_device_builder/controllers/devices/controller.py

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from esphome.core import CORE
1818
from esphome.zeroconf import AsyncEsphomeZeroconf
1919

20-
from ...constants import is_secrets_file
20+
from ...constants import SECRETS_FILENAME, is_secrets_file
2121
from ...helpers.api import CommandError, api_command
2222
from ...helpers.async_ import create_logged_task, drain_tasks, run_in_executor
2323
from ...helpers.build_size import BuildSizeRefreshResult
@@ -26,10 +26,9 @@
2626
from ...helpers.secrets_state import (
2727
SecretsContentError,
2828
read_secrets_yaml,
29+
set_wifi_secrets,
2930
validate_secrets_content,
30-
validate_wifi_credentials,
3131
wifi_secrets_defined,
32-
write_wifi_secrets,
3332
)
3433
from ...helpers.sibling_cli import find_esphome_cmd
3534
from ...helpers.storage import ShutdownCallback, drain_shutdown_callbacks
@@ -680,12 +679,8 @@ async def _yaml_content_for_create(
680679
# that's benign — ``wifi_ssid`` / ``wifi_password`` is a shared,
681680
# idempotent upsert (identical to ``config/set_wifi_credentials``)
682681
# that the next device reuses, not per-device state.
683-
try:
684-
validate_wifi_credentials(ssid, psk)
685-
except SecretsContentError as err:
686-
raise CommandError(ErrorCode.INVALID_ARGS, str(err)) from err
687-
await self._db.write_secrets_locked(
688-
write_wifi_secrets, self._db.settings.config_dir, ssid, psk
682+
await set_wifi_secrets(
683+
self._db.write_secrets_locked, self._db.settings.config_dir, ssid, psk
689684
)
690685
ssid, psk = "", "" # force the !secret path in the generator
691686
wifi_requested = True
@@ -738,6 +733,7 @@ async def _validate_rewritten_yaml_or_raise(
738733
packages_span=packages_span,
739734
packages_root=self._packages_root,
740735
failure_tail=failure_tail,
736+
secrets_path=self._db.settings.config_dir / SECRETS_FILENAME,
741737
)
742738

743739
@api_command("devices/delete")

esphome_device_builder/controllers/devices/mutations_yaml.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
generate_device_yaml,
1616
generate_minimal_stub_yaml,
1717
)
18+
from ...helpers.secrets_state import secrets_problem, secrets_unparsable_message
19+
from ...helpers.yaml.marks import marked_paths, trim_marks
1820
from ...helpers.yaml.scan import block_end_index, find_block_header
1921
from ...models import ErrorCode
2022
from ..editor import ValidatorUnavailableError
@@ -139,6 +141,7 @@ async def validate_rewritten_yaml_or_raise(
139141
packages_span: tuple[int, int] | None = None,
140142
packages_root: Path | None = None,
141143
failure_tail: str | None = None,
144+
secrets_path: Path | None = None,
142145
) -> str | None:
143146
"""
144147
Schema-validate *content* via the editor; raise if invalid.
@@ -163,7 +166,8 @@ async def validate_rewritten_yaml_or_raise(
163166
off-loop (``CORE.data_dir`` stats the disk).
164167
165168
*failure_tail* overrides the ``INVALID_ARGS`` refusal's closing
166-
sentence.
169+
sentence. An error marked inside *secrets_path* (the config dir's
170+
``secrets.yaml``) refuses as ``INVALID_ARGS`` naming that file.
167171
"""
168172
if editor is None:
169173
return None
@@ -211,7 +215,11 @@ async def validate_rewritten_yaml_or_raise(
211215
succeeded = True
212216
return warning
213217
_raise_validation_failure(
214-
errors, action=action, on_failure=on_failure, failure_tail=failure_tail
218+
errors,
219+
action=action,
220+
on_failure=on_failure,
221+
failure_tail=failure_tail,
222+
secrets_path=secrets_path,
215223
)
216224
finally:
217225
if not succeeded and on_error_cleanup is not None:
@@ -231,8 +239,17 @@ def _raise_validation_failure(
231239
action: str,
232240
on_failure: ErrorCode,
233241
failure_tail: str | None,
242+
secrets_path: Path | None,
234243
) -> NoReturn:
235244
"""Raise the refusal ``CommandError`` for a failed validation."""
245+
if (problem := _secrets_file_problem(errors, secrets_path)) is not None:
246+
# The user's secrets.yaml is theirs to fix, whatever ``on_failure`` says.
247+
if on_failure is ErrorCode.INTERNAL_ERROR:
248+
# Action only: the clause names keys from the user's secrets file.
249+
_LOGGER.warning(
250+
"Refusing %s: errors sit in the user's secrets.yaml, not the generator", action
251+
)
252+
raise CommandError(ErrorCode.INVALID_ARGS, secrets_unparsable_message(action, problem))
236253
if on_failure is ErrorCode.INTERNAL_ERROR:
237254
message_tail = (
238255
". Please report this with a redacted snippet of just the "
@@ -304,6 +321,19 @@ def _summarise(errors: list[str]) -> str:
304321
return ("; ".join(shown) + suffix).removesuffix(".")
305322

306323

324+
def _secrets_file_problem(errors: list[str], secrets_path: Path | None) -> str | None:
325+
"""Return the ``secrets.yaml <problem>`` clause when every error is marked inside it."""
326+
if secrets_path is None or not errors:
327+
return None
328+
# Path equality is string-only (this runs on the event loop); both sides
329+
# carry the same config_dir string, relative or not.
330+
if not all(any(Path(p) == secrets_path for p in marked_paths(msg)) for msg in errors):
331+
return None
332+
if len(errors) == 1 and all(Path(p) == secrets_path for p in marked_paths(errors[0])):
333+
return secrets_problem(errors[0])
334+
return f"doesn't parse: {_summarise([trim_marks(msg) for msg in errors])}"
335+
336+
307337
def _entry_confined_to_packages(
308338
entry: dict, packages_span: tuple[int, int], packages_root: Path
309339
) -> bool:

0 commit comments

Comments
 (0)