Skip to content

Add devices/archive, devices/unarchive, devices/list_archived#105

Merged
bdraco merged 8 commits into
mainfrom
devices-archive
May 2, 2026
Merged

Add devices/archive, devices/unarchive, devices/list_archived#105
bdraco merged 8 commits into
mainfrom
devices-archive

Conversation

@bdraco
Copy link
Copy Markdown
Member

@bdraco bdraco commented May 2, 2026

Summary

Adds backend support for the dashboard's archive flow — soft-delete that's reversible, mirrors the legacy ESPHome dashboard's ArchiveRequestHandler / UnArchiveRequestHandler. Pairs with frontend PR esphome/device-builder-frontend#86.

Commands

Command Args Returns Notes
devices/archive {configuration} Move YAML to <config_dir>/archive/, wipe build dir, wipe StorageJSON + metadata sidecars. Reversible via unarchive.
devices/unarchive {configuration} Move YAML back. Errors with INVALID_ARGS if an active config of the same name already exists.
devices/list_archived [{configuration, name, friendly_name, comment}] Listing for the dashboard's Archived-devices dialog.
devices/delete_archived {configuration} Permanently delete an archived YAML and its sidecars. Symmetric companion to archive.

Design decisions

Sidecar wipe on archive

_archive_single removes the StorageJSON sidecar and device-metadata entry alongside the build dir. A previous iteration left them in place so unarchive could restore the cached IP / version / config_hash / loaded_integrations, but per-filename keying meant a future same-name configuration would inherit the archived device's stale state until recompiled. Wiping on archive trades a few seconds of "unknown state" after unarchive (the scanner + monitor refill from the next mDNS broadcast) for full isolation between archive and any future same-name device.

Same-name collision: refuse, don't suffix-bump

_archive_single rejects with CommandError(INVALID_ARGS) when an archive entry of the same name already exists, instead of renaming the new archive to <name> (2).yaml. The earlier suffix-bump approach orphaned the suffixed copy from its sidecars (sidecars stay keyed on the original filename), so unarchive of the bumped copy would surface without its cached state. Safer to make the user resolve the collision explicitly (unarchive the existing copy or permanently delete it first).

Path-traversal validator at the public boundary

_validate_archive_configuration runs at the top of every archive command and rejects anything that isn't a pure basename (no path separators, no .., no NUL byte, no . / .., fails POSIX or Windows .name round-trip). Each helper builds paths from the user-supplied filename (<config_dir>/archive/<configuration>, ext_storage_path(configuration)data_dir/storage/<configuration>.json) that don't all flow through Settings.rel_path, so structural protection alone wasn't sufficient. 11 traversal payloads × 3 commands all rejected with INVALID_ARGS. Defense-in-depth follow-up for other handlers tracked at #107.

delete_archived defensive guard

_delete_archived_single skips sidecar removal when an active config of the same filename exists. With archive-side sidecar wipe in place, this collision shouldn't arise from new archives, but archives created by the legacy dashboard or before this PR could still hit it — and removing the sidecars would wipe the live device's cached state.

List fallback

_list_archived_sync falls back to StorageJSON.name / friendly_name / comment when the archived YAML's esphome: block is sparse (e.g. friendly name only ever lived in the sidecar because the user wrote it via the dashboard's edit-name dialog rather than mutating the YAML). Bare-filename is the third fallback.

Error code translation

archive_device, unarchive_device, delete_archived wrap the corresponding helpers and translate FileNotFoundError to CommandError(NOT_FOUND). Unarchive additionally translates FileExistsError (active config already at the target filename) to CommandError(INVALID_ARGS). Without these the WS layer would surface them as generic INTERNAL_ERROR.

Test plan

  • 33 archive-specific tests covering: archive moves YAML, wipes build dir, wipes storage + metadata sidecars; collision raises INVALID_ARGS; missing file raises NOT_FOUND at WS boundary; unarchive moves back, refuses clobber, NOT_FOUND mapping; list_archived parses meta, skips non-YAML / hidden, falls back to filename, falls back to StorageJSON; delete_archived removes YAML + sidecars, preserves active-same-name sidecars, NOT_FOUND mapping; 11 path-traversal payloads × 3 commands all rejected with INVALID_ARGS.
  • 515 backend tests total, all green.

Mirrors the legacy dashboard's archive flow
(``esphome/dashboard/web_server.py:ArchiveRequestHandler`` /
``UnArchiveRequestHandler``) — a soft-delete that moves the YAML
to ``<config_dir>/archive/`` and wipes the per-device PlatformIO
build tree, but keeps the YAML on disk so the user can
``unarchive`` later.

Three new WS commands:

- ``devices/archive`` — move + wipe build, scanner re-fires
  ``DEVICE_REMOVED`` so the dashboard's active list refreshes.
- ``devices/unarchive`` — move back to ``config_dir``; refuses
  to clobber an active YAML of the same name and surfaces a
  ``CommandError(INVALID_ARGS)`` so the dialog can prompt for a
  rename or explicit overwrite.
- ``devices/list_archived`` — read ``<config_dir>/archive/``,
  parse each YAML's ``esphome:`` block, return name /
  friendly_name / comment so the dashboard's "Show archived
  devices" toggle can render rows + Unarchive / Delete-permanently
  controls.

Edge cases pinned by tests:

- Re-archiving a device with the same name appends a numeric
  suffix (``kitchen.yaml`` → ``kitchen (2).yaml``) rather than
  clobbering the earlier copy.
- Archiving a never-compiled device (no StorageJSON, no build
  tree) succeeds — the archive flow is the natural "I just made
  this YAML and don't want it" escape hatch.
- ``list_archived`` skips non-YAML and hidden files (``.DS_Store``,
  ``notes.txt``, ``.hidden.yaml``) so a single stray file in a
  user-managed archive directory doesn't poison the listing.
- A YAML whose ``esphome:`` block doesn't parse falls back to
  surfacing the filename — the user's only handle on a legacy
  archive entry.
Copilot AI review requested due to automatic review settings May 2, 2026 16:22
@bdraco bdraco added the new-feature New feature label May 2, 2026
@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 2, 2026

Codecov Report

❌ Patch coverage is 97.63780% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.25%. Comparing base (10b614f) to head (fcd15e8).

Files with missing lines Patch % Lines
esphome_device_builder/controllers/devices.py 97.63% 3 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #105      +/-   ##
==========================================
+ Coverage   64.30%   65.25%   +0.95%     
==========================================
  Files          36       36              
  Lines        4572     4686     +114     
==========================================
+ Hits         2940     3058     +118     
+ Misses       1632     1628       -4     
Flag Coverage Δ
py3.12 64.98% <97.63%> (+0.96%) ⬆️
py3.14 65.25% <97.63%> (+0.95%) ⬆️

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

Files with missing lines Coverage Δ
esphome_device_builder/controllers/devices.py 70.14% <97.63%> (+5.53%) ⬆️

... and 1 file with indirect coverage changes

🚀 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 backend support for archiving devices in the dashboard: moving YAMLs into an archive area, restoring them later, and listing archived entries for future UI integration. This fits into the existing DevicesController WebSocket API by extending device lifecycle operations without permanently deleting user configs.

Changes:

  • Add new WebSocket commands: devices/archive, devices/unarchive, and devices/list_archived.
  • Implement archive/unarchive/list logic in DevicesController, including build-directory cleanup on archive.
  • Add focused tests and API documentation for the new archive flow.

Reviewed changes

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

File Description
esphome_device_builder/controllers/devices.py Adds the new archive/unarchive/list command handlers and their filesystem logic.
tests/test_archive_device.py Adds unit tests for archive moves, build cleanup, collisions, unarchive behavior, and archived listing.
docs/API.md Documents the three new WebSocket API commands and their payloads.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread esphome_device_builder/controllers/devices.py Outdated
Comment thread esphome_device_builder/controllers/devices.py
Comment thread esphome_device_builder/controllers/devices.py
Comment thread esphome_device_builder/controllers/devices.py
bdraco added 2 commits May 2, 2026 11:35
…g, StorageJSON fallback

- Drop the suffix-bump dance in _archive_single. Renaming the
  archive copy to '<name> (2).yaml' would orphan it from the
  StorageJSON sidecar (still keyed on the original filename),
  so a later unarchive of the suffixed copy would lose the
  cached address / version / loaded_integrations. Refuse the
  collision with CommandError(INVALID_ARGS) and let the user
  resolve it explicitly.
- Wrap archive_device / unarchive_device so a missing YAML
  surfaces as CommandError(NOT_FOUND) over the WS rather than
  bubbling up as a generic INTERNAL_ERROR.
- _list_archived_sync now falls back to StorageJSON.name /
  friendly_name / comment when the archived YAML's esphome:
  block is sparse. Friendly name often only lives in the
  sidecar (the dashboard's edit-name dialog writes it there
  rather than mutating the YAML), so without this the
  archived listing regressed to bare filenames for those
  devices.
- Tests updated: collision now expects CommandError, plus new
  cases for archive_device / unarchive_device NOT_FOUND
  translation and the StorageJSON-fallback path.
Path-traversal audit: archive / unarchive / delete_archived all
build paths from the user-supplied filename
(`<config_dir>/archive/<configuration>`,
`ext_storage_path(configuration)`) that don't all flow through
`Settings.rel_path`. A configuration like `../etc/passwd` or
`subdir/../foo` could resolve outside the archive tree and be
unlinked or overwritten — for delete_archived nothing else
interposed. Reject anything that isn't a pure basename at the
public-command boundary via `_validate_archive_configuration`
(checks empty, `.` / `..`, path separators, NUL byte, and
both POSIX + Windows .name round-trip). 11 traversal cases x 3
commands all rejected with INVALID_ARGS.

Adds `devices/delete_archived` for the frontend's
Delete-permanently button — symmetric with archive: removes the
YAML + StorageJSON sidecar + device-metadata sidecar.
FileNotFoundError translates to CommandError(NOT_FOUND).

Tests + docs updated. 30 archive tests, 512 total.
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.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread esphome_device_builder/controllers/devices.py Outdated
Comment thread esphome_device_builder/controllers/devices.py Outdated
Comment thread esphome_device_builder/controllers/devices.py
bdraco added 4 commits May 2, 2026 12:22
Address Copilot review on PR #105:

1. _archive_single now wipes the StorageJSON sidecar AND the
   device-metadata entry alongside the build dir. Previously
   left them in place so unarchive could restore cached state
   (address / version / hash / loaded_integrations), but
   per-filename keying means a future same-name configuration
   would inherit the archived device's stale state until
   recompiled or edited. Trade a few seconds of "unknown
   state" after unarchive (the scanner + monitor refill from
   the next mDNS broadcast) for full isolation between
   archive and any future same-name device. Updated docstrings
   to reflect.

2. _delete_archived_single now skips sidecar removal when an
   active config of the same filename exists. Defensive — with
   #1 in place this collision shouldn't arise, but if the
   archive predates this PR (or was written by the legacy
   dashboard which kept sidecars), the StorageJSON / metadata
   belong to the live device and removing them would wipe its
   cached IP / hash / loaded_integrations.

Path-traversal verification: 11 traversal payloads x 3 archive
commands all still rejected with INVALID_ARGS. The new code
paths (sidecar wipe in archive, active-config check in
delete_archived) all run after _validate_archive_configuration
at the public boundary; the metadata file is hardcoded
(.device-builder.json) and configuration is used only as a
dict key.

Tests: 33 archive-specific (3 new), 515 backend total.
Same shape lived in three places: _archive_single,
_delete_archived_single, and _delete_single. The build-dir
wipe (StorageJSON.build_path -> shutil.rmtree) and the
sidecar cleanup (storage_path.unlink + remove_device_metadata
with logging) were copy-pasted across them. Refactor pointed
out during PR review.

Pull both into module-level helpers near
_validate_archive_configuration:

- _wipe_device_build_dir(configuration) — load StorageJSON,
  rmtree build_path. No-op when sidecar missing or never built.
- _remove_device_sidecars(config_dir, configuration) — unlink
  StorageJSON sidecar + remove device-metadata entry, both
  best-effort with warning logs.

Net: -28 lines. Behavior identical (515 backend tests still
green).
PR coverage was at 90.59% with 11 missing lines. Added 5 tests
to cover the gaps:

- test_archive_device_full_flow_calls_scanner — end-to-end
  archive_device() (the WS-command wrapper, not just the
  helper) + verifies the trailing _scanner.scan() call.
- test_unarchive_device_full_flow_calls_scanner — same shape
  for unarchive_device().
- test_list_archived_full_flow — runs list_archived() through
  its loop.run_in_executor() wrapper; helper-level tests had
  been calling _list_archived_sync directly.
- test_remove_device_sidecars_logs_oserror_on_storage_unlink —
  monkeypatch Path.unlink to raise OSError; assert the
  warning is logged and the helper doesn't propagate.
- test_remove_device_sidecars_logs_exception_on_metadata_remove —
  same shape for remove_device_metadata raising
  RuntimeError.

38 archive-specific tests (5 new), 520 backend total.
set_device_metadata / get_device_metadata go through
metadata_transaction which calls tempfile.mkstemp; that calls
os.path.abspath under the hood which blockbuster (CI's
blocking-call detector) flags from an async context. The
local pytest run passed because blockbuster fires only in CI.

Wrap the test's metadata setup + assertion in asyncio.to_thread
so the blocking calls run off the event loop.
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.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread esphome_device_builder/controllers/devices.py Outdated
Comment thread esphome_device_builder/controllers/devices.py Outdated
Comment thread docs/API.md Outdated
- delete_archived docstring now describes archive sidecars as
  'usually already gone' (archive wipes them on the way in),
  with delete_archived's cleanup framed as covering legacy
  archives + the active-same-name guard.
- _list_archived_sync docstring frames the StorageJSON fallback
  as a legacy-only path: server-created archives no longer have
  sidecars, so the fallback only matters for upstream-dashboard
  archives or pre-sidecar-wipe entries.
- docs/API.md spells out the full archive teardown (build dir,
  StorageJSON, device-metadata) and notes that cached state
  refills from mDNS after unarchive. Same file's
  list_archived row drops the 'Show archived devices toggle'
  reference and uses 'archived-devices dialog' to match the
  frontend's actual UI shape.
@bdraco bdraco merged commit c38d428 into main May 2, 2026
9 checks passed
@bdraco bdraco deleted the devices-archive branch May 2, 2026 17:44
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