Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 599b8e0b7d | |||
| 313fcf94e7 | |||
| d976ab56e3 | |||
| 80c1c2966f | |||
| de3fbf1a12 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -174,3 +174,6 @@ cython_debug/
|
|||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
|
# Local project instructions
|
||||||
|
.idea/start prompt.md
|
||||||
|
|
||||||
|
|||||||
6
.idea/changes.md
generated
6
.idea/changes.md
generated
@@ -19,6 +19,12 @@
|
|||||||
- Started the blueprint/category-aware installer preparation: installation metadata now distinguishes install type and installed paths, repo payloads expose install targets, and the active panel shows install-target context for future non-integration categories.
|
- Started the blueprint/category-aware installer preparation: installation metadata now distinguishes install type and installed paths, repo payloads expose install targets, and the active panel shows install-target context for future non-integration categories.
|
||||||
- Added initial blueprint install-path handling groundwork so the codebase is no longer fully hard-wired to `custom_components/`.
|
- Added initial blueprint install-path handling groundwork so the codebase is no longer fully hard-wired to `custom_components/`.
|
||||||
- Bumped the integration version from `0.7.4` to `0.7.5` and added the `0.7.5` release entry to `CHANGELOG.md` for blueprint support and the documentation refresh.
|
- Bumped the integration version from `0.7.4` to `0.7.5` and added the `0.7.5` release entry to `CHANGELOG.md` for blueprint support and the documentation refresh.
|
||||||
|
- Fixed blueprint update backups: blueprint file updates now create content backups before overwrite and can roll back copied blueprint files if installation fails.
|
||||||
|
- Kept the no-restart behavior for blueprints, because blueprint deployment does not normally require a Home Assistant restart.
|
||||||
|
- Restored blueprint backup/restore availability in the UI and backend: the restore button is visible again for blueprint installs, blueprint backups can be listed, and blueprint content can now be restored from backup without forcing a restart.
|
||||||
|
- Fixed category-specific uninstall and restore messaging in the active panel: blueprints now reference `/config/blueprints` and explicitly state that no restart is required, while integrations keep the `/config/custom_components` restart warning.
|
||||||
|
- Updated the public documentation set (`README.md`, `README_DEVELOPER.md`, `README_FULL.md`, and `CHANGELOG.md`) for the completed blueprint backup, restore, uninstall, and restart-behavior work, and bumped the integration version to `0.7.6` for the next release.
|
||||||
|
- Fixed stale Home Assistant update entities after uninstall: BCS now removes orphaned update entities from the running platform state and entity registry when an installed repository or blueprint is uninstalled.
|
||||||
|
|
||||||
### Documented
|
### Documented
|
||||||
- Captured the verified project identity from the repository and README files: Bahmcloud Store is a Home Assistant custom integration intended to behave like a provider-neutral store for custom integrations, similar to HACS but broader than GitHub-only workflows.
|
- Captured the verified project identity from the repository and README files: Bahmcloud Store is a Home Assistant custom integration intended to behave like a provider-neutral store for custom integrations, similar to HACS but broader than GitHub-only workflows.
|
||||||
|
|||||||
134
.idea/start prompt.md
generated
134
.idea/start prompt.md
generated
@@ -1,134 +0,0 @@
|
|||||||
## Bahmcloud Store Start Prompt
|
|
||||||
|
|
||||||
You are working in the `Bahmcloud Store` repository.
|
|
||||||
|
|
||||||
Project identity:
|
|
||||||
- This is a Home Assistant custom integration named `bahmcloud_store`.
|
|
||||||
- The product goal is a provider-neutral store for Home Assistant custom integrations, similar in spirit to HACS, but not limited to GitHub.
|
|
||||||
- Current real provider implementation is strongest for GitHub, GitLab, and Gitea-compatible providers. Unknown providers currently fall through the Gitea-style code paths, so do not assume every arbitrary Git provider works without verification.
|
|
||||||
- The long-term product direction is broader than integrations only: the store should evolve into a Home Assistant content store that can manage integrations, blueprints, templates, Lovelace designs, and additional future categories from multiple git providers.
|
|
||||||
|
|
||||||
Working rules:
|
|
||||||
- All project-facing work must be done in English only, regardless of the language the user speaks in chat. This applies to code comments, documentation, changelog entries, release notes, commit messages, PR text, UI text, issue text, and any other project artifacts unless the user explicitly requests a specific exception for repository content.
|
|
||||||
- Never push, commit, tag, or create a release without explicit user approval.
|
|
||||||
- Always document every change in `.idea/changes.md` with the current date and a detailed description of what changed, why it changed, and any verification done.
|
|
||||||
- If a new release is created, update `CHANGELOG.md` with all relevant changes since the last released version.
|
|
||||||
- Never mention prompts, AI tools, KI tools, or files inside the `.idea` folder in commit messages, push descriptions, release notes, or changelog entries.
|
|
||||||
- Use plain version numbers for future git tags and release names, without a leading `v` prefix. Example: `0.7.4`, not `v0.7.4`.
|
|
||||||
- Never mention internal workflow rules or repository-internal prompt/process decisions in any public-facing project communication. This includes commit messages, push descriptions, git tags, release names, release notes, changelog entries, and similar outward-facing texts.
|
|
||||||
- Prefer changing the real active code paths, not legacy or duplicate files.
|
|
||||||
- When docs and code disagree, trust the current code first, then update docs to match verified behavior.
|
|
||||||
- Do not remove user changes or perform destructive git actions unless the user explicitly asks for them.
|
|
||||||
|
|
||||||
Repository facts to keep in mind:
|
|
||||||
- Main integration path: `custom_components/bahmcloud_store/`
|
|
||||||
- Entry point: `__init__.py`
|
|
||||||
- Main runtime/service layer: `core.py`
|
|
||||||
- HTTP API layer: `views.py`
|
|
||||||
- Provider detection and remote version/readme fetching: `providers.py`
|
|
||||||
- Repo metadata loading (`bcs.yaml`, `hacs.yaml`, `hacs.json`): `metadata.py`
|
|
||||||
- Persistent storage: `storage.py`
|
|
||||||
- Update entities: `update.py`
|
|
||||||
- Repairs restart flow: `repairs.py`
|
|
||||||
- Frontend panel actually loaded by Home Assistant: `panel/panel.js`
|
|
||||||
- Panel registration uses `/api/bahmcloud_store_static/panel.js?v=109`; if frontend assets change in a real release, bump the version query to break HA browser cache.
|
|
||||||
|
|
||||||
Current architecture summary:
|
|
||||||
- Setup is config-entry only. YAML configuration is intentionally unsupported and only logs a warning if present.
|
|
||||||
- Only one integration instance is allowed.
|
|
||||||
- The official store index URL is fixed in `const.py`:
|
|
||||||
`https://git.bahmcloud.de/bahmcloud/ha_store/raw/branch/main/store.yaml`
|
|
||||||
- The store index format currently used by the active code is a YAML mapping with:
|
|
||||||
- `refresh_seconds`
|
|
||||||
- `repos`
|
|
||||||
- each repo entry contains at least `url`, optionally `name` and `category`
|
|
||||||
- Refresh flow:
|
|
||||||
- integration initializes storage and caches
|
|
||||||
- after `homeassistant_started`, a delayed refresh runs
|
|
||||||
- periodic refresh also runs on an interval from the store index
|
|
||||||
- refresh merges official index repos, optional HACS repos, and user custom repos
|
|
||||||
- cached enrichment is applied first, installed repos are refreshed eagerly, and the rest are enriched in the background
|
|
||||||
- Optional HACS support exists behind the `hacs_enabled` setting and currently loads the official HACS integration list from `data-v2.hacs.xyz`. That path is GitHub-only metadata, not a general provider abstraction.
|
|
||||||
- Install/update flow:
|
|
||||||
- picks a ref from selected version, latest version, or default branch
|
|
||||||
- downloads a ZIP from the provider-specific archive endpoint
|
|
||||||
- extracts the repository
|
|
||||||
- finds `custom_components`
|
|
||||||
- installs every integration folder inside `custom_components/*` that contains `manifest.json`
|
|
||||||
- stores the installed ref and manifest version in HA storage
|
|
||||||
- creates backups before overwriting existing domains
|
|
||||||
- marks restart required through Repairs
|
|
||||||
- Backup/restore behavior:
|
|
||||||
- backups live under `/config/.bcs_backups/<domain>/<timestamp>/`
|
|
||||||
- restore updates stored installed-version info so the UI and update entities reflect the restored state
|
|
||||||
- retention is currently 5 backups per domain
|
|
||||||
- Installed-state reconciliation exists:
|
|
||||||
- if folders are deleted from `/config/custom_components`, stale installed entries are removed from storage
|
|
||||||
- BCS also tries to self-reconcile as installed when it was deployed externally
|
|
||||||
|
|
||||||
Public/API contract to preserve unless intentionally changed:
|
|
||||||
- `GET /api/bcs`
|
|
||||||
- `POST /api/bcs?action=refresh`
|
|
||||||
- `GET /api/bcs/settings`
|
|
||||||
- `POST /api/bcs/settings`
|
|
||||||
- `GET /api/bcs/readme?repo_id=...`
|
|
||||||
- `GET /api/bcs/versions?repo_id=...`
|
|
||||||
- `GET /api/bcs/repo?repo_id=...`
|
|
||||||
- `POST /api/bcs/install?repo_id=...&version=...`
|
|
||||||
- `POST /api/bcs/update?repo_id=...&version=...`
|
|
||||||
- `POST /api/bcs/uninstall?repo_id=...`
|
|
||||||
- `GET /api/bcs/backups?repo_id=...`
|
|
||||||
- `POST /api/bcs/restore?repo_id=...&backup_id=...`
|
|
||||||
- `POST /api/bcs/restart`
|
|
||||||
- `DELETE /api/bcs/custom_repo?id=...`
|
|
||||||
|
|
||||||
Storage model:
|
|
||||||
- Home Assistant storage key: `bcs_store`
|
|
||||||
- Stored sections:
|
|
||||||
- `custom_repos`
|
|
||||||
- `installed_repos`
|
|
||||||
- `settings`
|
|
||||||
- `hacs_cache`
|
|
||||||
- `repo_cache`
|
|
||||||
|
|
||||||
Frontend/UI facts:
|
|
||||||
- The active HA panel script is `custom_components/bahmcloud_store/panel/panel.js`.
|
|
||||||
- The richer UI is implemented there: source filtering, HACS toggle, repo detail loading, version selection, backups restore modal, restart action, and history handling.
|
|
||||||
- `panel/app.js`, `panel/index.html`, and `panel/styles.css` look like older standalone or legacy panel assets. Treat them as secondary unless you confirm they are still used for a real path.
|
|
||||||
|
|
||||||
Code-analysis findings that should influence future work:
|
|
||||||
- `store.py` represents an older store implementation with a different data model (`packages`, `source_path`, older API routes). It does not appear to be the active runtime path for the current integration flow.
|
|
||||||
- `custom_repo_view.py` duplicates logic that already exists in `views.py` and looks legacy/unreferenced.
|
|
||||||
- README files describe the project direction correctly at a high level, but some wording overstates provider generality. The actual code is provider-neutral in intent, but concretely implemented around GitHub, GitLab, and Gitea-style endpoints.
|
|
||||||
- The end-user and full READMEs contain some stale or inconsistent details compared with the current UI and code. Verify behavior in source before using README text as specification.
|
|
||||||
- There are visible encoding/mojibake issues in some documentation and older UI assets. Preserve valid UTF-8 when editing.
|
|
||||||
|
|
||||||
Planned product expansion:
|
|
||||||
1. Add support for Home Assistant blueprints and install them directly into the correct Home Assistant blueprint location from the store.
|
|
||||||
2. Add templates and Lovelace designs to the store so they can be discovered and installed from the same UI.
|
|
||||||
3. Add support for more categories beyond integrations and design the architecture so category-specific install targets and validation rules are explicit.
|
|
||||||
|
|
||||||
Implications for future architecture:
|
|
||||||
- The current install pipeline is integration-centric because it assumes repository content under `custom_components/`.
|
|
||||||
- Future category support should move toward category-aware install strategies instead of one universal install path.
|
|
||||||
- Store metadata and index entries will likely need stronger category typing, install-target definitions, and validation rules per category.
|
|
||||||
|
|
||||||
Project constraints to respect in future edits:
|
|
||||||
- Keep async I/O non-blocking in Home Assistant.
|
|
||||||
- Avoid startup-heavy network work before HA is fully started.
|
|
||||||
- Preserve repo-cache and HACS-cache behavior unless intentionally redesigning refresh logic.
|
|
||||||
- Preserve backup-before-overwrite safety for install/update/restore flows.
|
|
||||||
- Preserve update-entity behavior for installed repos.
|
|
||||||
- Keep the integration UI-admin-only and config-entry-based.
|
|
||||||
|
|
||||||
Recommended workflow for future tasks:
|
|
||||||
1. Read `README.md`, `README_DEVELOPER.md`, and relevant source files.
|
|
||||||
2. Verify whether the requested change belongs in active code or in legacy files.
|
|
||||||
3. Implement the change in the active runtime path.
|
|
||||||
4. Update documentation if behavior changed.
|
|
||||||
5. Append a detailed dated entry to `.idea/changes.md`.
|
|
||||||
6. If and only if a release is being prepared with user approval, fold release-worthy changes into `CHANGELOG.md`.
|
|
||||||
|
|
||||||
Current release baseline:
|
|
||||||
- `manifest.json` version is `0.7.5`
|
|
||||||
- Latest documented release in `CHANGELOG.md` is `0.7.5` dated `2026-03-23`
|
|
||||||
14
CHANGELOG.md
14
CHANGELOG.md
@@ -11,6 +11,20 @@ Sections:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 0.7.6 - 2026-03-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Blueprint backup listing and restore support in the repository detail view.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Install, update, uninstall, and restore feedback now reflects whether a restart is actually required for the selected content type.
|
||||||
|
- Blueprint restore dialogs and action messages now reference `/config/blueprints` instead of integration-only paths.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Blueprint updates now create usable content backups before overwriting files.
|
||||||
|
- Blueprint backups can be restored again from the active store UI after the previous restore-button regression.
|
||||||
|
- Blueprint uninstall confirmation text now matches the real uninstall behavior and no longer incorrectly warns about `/config/custom_components` or mandatory restarts.
|
||||||
|
|
||||||
## 0.7.5 - 2026-03-23
|
## 0.7.5 - 2026-03-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ This README is for end users.
|
|||||||
- Pin repositories so important items stay easy to find
|
- Pin repositories so important items stay easy to find
|
||||||
- Use native Home Assistant update entities for installed integrations
|
- Use native Home Assistant update entities for installed integrations
|
||||||
- Create and restore backups for integration installs and updates
|
- Create and restore backups for integration installs and updates
|
||||||
|
- Create and restore blueprint content backups during blueprint updates and restore actions
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -74,7 +75,7 @@ Installed to:
|
|||||||
|
|
||||||
- Expected repository content: `blueprints/...`
|
- Expected repository content: `blueprints/...`
|
||||||
- Install target: `/config/blueprints/...`
|
- Install target: `/config/blueprints/...`
|
||||||
- Supports install and uninstall through the store
|
- Supports install, update, uninstall, backup, and restore through the store
|
||||||
- No restart is normally required for blueprint deployment
|
- No restart is normally required for blueprint deployment
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -83,4 +84,3 @@ Installed to:
|
|||||||
|
|
||||||
- Developer documentation: `README_DEVELOPER.md`
|
- Developer documentation: `README_DEVELOPER.md`
|
||||||
- Full user guide: `README_FULL.md`
|
- Full user guide: `README_FULL.md`
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ Category currently matters operationally:
|
|||||||
|
|
||||||
- Expected source layout: `blueprints/...`
|
- Expected source layout: `blueprints/...`
|
||||||
- Install target: `/config/blueprints/...`
|
- Install target: `/config/blueprints/...`
|
||||||
- Initial support is focused on direct install and uninstall
|
- Supports install, update, uninstall, content backup, and content restore
|
||||||
|
- Restores and uninstalls are path-based and operate on the recorded installed blueprint files
|
||||||
- Category-aware groundwork is in place so future content types can use their own install strategies
|
- Category-aware groundwork is in place so future content types can use their own install strategies
|
||||||
|
|
||||||
## HTTP API
|
## HTTP API
|
||||||
@@ -117,7 +118,7 @@ Base path: `/api/bcs`
|
|||||||
- README rendering
|
- README rendering
|
||||||
- Release notes rendering
|
- Release notes rendering
|
||||||
- Version selection
|
- Version selection
|
||||||
- Backup restore UI for integrations
|
- Backup restore UI for integrations and blueprints
|
||||||
|
|
||||||
## Contributing to the Official Store Index
|
## Contributing to the Official Store Index
|
||||||
|
|
||||||
@@ -156,4 +157,3 @@ The long-term architecture should remain category-aware:
|
|||||||
- category -> UI affordances
|
- category -> UI affordances
|
||||||
|
|
||||||
This is especially important before Templates and Lovelace support are added, because those should stay compatible with established HACS expectations where possible.
|
This is especially important before Templates and Lovelace support are added, because those should stay compatible with established HACS expectations where possible.
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ That makes update checks more reliable when a repository uses tags or releases t
|
|||||||
|
|
||||||
- Source layout: `blueprints/...`
|
- Source layout: `blueprints/...`
|
||||||
- Install target: `/config/blueprints/...`
|
- Install target: `/config/blueprints/...`
|
||||||
- Supports install and uninstall through the store
|
- Supports install, update, uninstall, backup, and restore through the store
|
||||||
- Intended for blueprint repositories without integration-specific folder structures
|
- Intended for blueprint repositories without integration-specific folder structures
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -150,7 +150,8 @@ Blueprint installs normally do not require a Home Assistant restart.
|
|||||||
|
|
||||||
### Blueprints
|
### Blueprints
|
||||||
|
|
||||||
- Blueprint repositories can also be reinstalled from another selected version.
|
- Blueprint repositories can also be updated or reinstalled from another selected version.
|
||||||
|
- Blueprint updates create content backups before overwriting files.
|
||||||
- The current blueprint path handling is focused on direct deployment to the blueprints folder.
|
- The current blueprint path handling is focused on direct deployment to the blueprints folder.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -167,6 +168,7 @@ Blueprint installs normally do not require a Home Assistant restart.
|
|||||||
|
|
||||||
- Removes the installed blueprint files recorded by BCS
|
- Removes the installed blueprint files recorded by BCS
|
||||||
- Cleans up empty directories below `/config/blueprints` when possible
|
- Cleans up empty directories below `/config/blueprints` when possible
|
||||||
|
- Does not require a restart under normal conditions
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -198,6 +200,7 @@ Notes:
|
|||||||
## Backups and Restore
|
## Backups and Restore
|
||||||
|
|
||||||
Integration installs and updates create backups before overwriting existing files.
|
Integration installs and updates create backups before overwriting existing files.
|
||||||
|
Blueprint updates also create content backups before overwriting deployed blueprint files.
|
||||||
|
|
||||||
Backup path:
|
Backup path:
|
||||||
|
|
||||||
@@ -211,7 +214,16 @@ Restore flow:
|
|||||||
4. Confirm restore
|
4. Confirm restore
|
||||||
5. Restart Home Assistant if prompted
|
5. Restart Home Assistant if prompted
|
||||||
|
|
||||||
Restore is currently intended for integrations.
|
Restore is available for integrations and blueprints.
|
||||||
|
|
||||||
|
Blueprint restore flow:
|
||||||
|
|
||||||
|
1. Open the blueprint repository detail
|
||||||
|
2. Click **Restore**
|
||||||
|
3. Select a backup
|
||||||
|
4. Confirm restore
|
||||||
|
|
||||||
|
Blueprint restores overwrite the recorded installed files under `/config/blueprints/...` and normally do not require a restart.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -273,7 +285,7 @@ Opening a repository detail view can force immediate enrichment for that reposit
|
|||||||
Restart usually matters only for integration changes.
|
Restart usually matters only for integration changes.
|
||||||
|
|
||||||
- Integration install/update/uninstall/restore: restart expected
|
- Integration install/update/uninstall/restore: restart expected
|
||||||
- Blueprint install/uninstall: restart usually not needed
|
- Blueprint install/update/uninstall/restore: restart usually not needed
|
||||||
|
|
||||||
BCS uses a Home Assistant repair flow to surface restart requirements for integration changes.
|
BCS uses a Home Assistant repair flow to surface restart requirements for integration changes.
|
||||||
|
|
||||||
|
|||||||
@@ -1335,6 +1335,11 @@ class BCSCore:
|
|||||||
|
|
||||||
await self.hass.async_add_executor_job(_mkdir)
|
await self.hass.async_add_executor_job(_mkdir)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _backup_repo_key(repo_id: str) -> str:
|
||||||
|
safe = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in str(repo_id or "").strip())
|
||||||
|
return safe or "repo"
|
||||||
|
|
||||||
def _build_backup_meta(self, repo_id: str, domain: str) -> dict[str, object]:
|
def _build_backup_meta(self, repo_id: str, domain: str) -> dict[str, object]:
|
||||||
"""Build metadata for backup folders so restores can recover the stored version."""
|
"""Build metadata for backup folders so restores can recover the stored version."""
|
||||||
inst = self.get_installed(repo_id) or {}
|
inst = self.get_installed(repo_id) or {}
|
||||||
@@ -1391,6 +1396,68 @@ class BCSCore:
|
|||||||
_LOGGER.info("BCS backup created: domain=%s path=%s", domain, backup_path)
|
_LOGGER.info("BCS backup created: domain=%s path=%s", domain, backup_path)
|
||||||
return backup_path
|
return backup_path
|
||||||
|
|
||||||
|
async def _backup_paths(
|
||||||
|
self,
|
||||||
|
repo_id: str,
|
||||||
|
relative_paths: list[str],
|
||||||
|
*,
|
||||||
|
meta: dict[str, object] | None = None,
|
||||||
|
) -> Path | None:
|
||||||
|
"""Backup arbitrary files under the Home Assistant config root."""
|
||||||
|
|
||||||
|
clean_paths = [str(p).strip().replace("\\", "/") for p in (relative_paths or []) if str(p).strip()]
|
||||||
|
if not clean_paths:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cfg_root = Path(self.hass.config.path(""))
|
||||||
|
existing: list[str] = []
|
||||||
|
for rel in clean_paths:
|
||||||
|
target = cfg_root / rel
|
||||||
|
if target.exists():
|
||||||
|
existing.append(rel)
|
||||||
|
if not existing:
|
||||||
|
return None
|
||||||
|
|
||||||
|
await self._ensure_backup_root()
|
||||||
|
|
||||||
|
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||||
|
repo_root = self._backup_root / "_content" / self._backup_repo_key(repo_id)
|
||||||
|
backup_path = repo_root / ts
|
||||||
|
|
||||||
|
def _do_backup() -> None:
|
||||||
|
repo_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
if backup_path.exists():
|
||||||
|
shutil.rmtree(backup_path, ignore_errors=True)
|
||||||
|
backup_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for rel in existing:
|
||||||
|
src = cfg_root / rel
|
||||||
|
dest = backup_path / rel
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if src.is_file():
|
||||||
|
shutil.copy2(src, dest)
|
||||||
|
elif src.is_dir():
|
||||||
|
shutil.copytree(src, dest, dirs_exist_ok=True)
|
||||||
|
|
||||||
|
if meta:
|
||||||
|
try:
|
||||||
|
meta_path = backup_path / BACKUP_META_FILENAME
|
||||||
|
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
backups = [p for p in repo_root.iterdir() if p.is_dir()]
|
||||||
|
backups.sort(key=lambda p: p.name, reverse=True)
|
||||||
|
for old in backups[self._backup_keep_per_domain :]:
|
||||||
|
shutil.rmtree(old, ignore_errors=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await self.hass.async_add_executor_job(_do_backup)
|
||||||
|
_LOGGER.info("BCS content backup created: repo_id=%s path=%s files=%s", repo_id, backup_path, len(existing))
|
||||||
|
return backup_path
|
||||||
|
|
||||||
async def _restore_domain_from_backup(self, domain: str, backup_path: Path) -> None:
|
async def _restore_domain_from_backup(self, domain: str, backup_path: Path) -> None:
|
||||||
"""Restore a domain folder from a backup."""
|
"""Restore a domain folder from a backup."""
|
||||||
dest_root = Path(self.hass.config.path("custom_components"))
|
dest_root = Path(self.hass.config.path("custom_components"))
|
||||||
@@ -1428,6 +1495,37 @@ class BCSCore:
|
|||||||
await self.hass.async_add_executor_job(_restore)
|
await self.hass.async_add_executor_job(_restore)
|
||||||
_LOGGER.info("BCS rollback applied: domain=%s from=%s", domain, backup_path)
|
_LOGGER.info("BCS rollback applied: domain=%s from=%s", domain, backup_path)
|
||||||
|
|
||||||
|
async def _restore_paths_from_backup(self, backup_path: Path, *, remove_targets: list[str] | None = None) -> None:
|
||||||
|
"""Restore arbitrary backed up files under the Home Assistant config root."""
|
||||||
|
|
||||||
|
cfg_root = Path(self.hass.config.path(""))
|
||||||
|
remove_list = [str(p).strip().replace("\\", "/") for p in (remove_targets or []) if str(p).strip()]
|
||||||
|
|
||||||
|
def _restore() -> None:
|
||||||
|
if not backup_path.exists() or not backup_path.is_dir():
|
||||||
|
return
|
||||||
|
|
||||||
|
for rel in remove_list:
|
||||||
|
target = cfg_root / rel
|
||||||
|
if target.exists():
|
||||||
|
if target.is_dir():
|
||||||
|
shutil.rmtree(target, ignore_errors=True)
|
||||||
|
elif target.is_file():
|
||||||
|
target.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
for src in backup_path.rglob("*"):
|
||||||
|
if not src.is_file():
|
||||||
|
continue
|
||||||
|
if src.name == BACKUP_META_FILENAME:
|
||||||
|
continue
|
||||||
|
rel = src.relative_to(backup_path)
|
||||||
|
dest = cfg_root / rel
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(src, dest)
|
||||||
|
|
||||||
|
await self.hass.async_add_executor_job(_restore)
|
||||||
|
_LOGGER.info("BCS content rollback applied: from=%s", backup_path)
|
||||||
|
|
||||||
async def list_repo_backups(self, repo_id: str) -> list[dict[str, Any]]:
|
async def list_repo_backups(self, repo_id: str) -> list[dict[str, Any]]:
|
||||||
"""List available backup sets for an installed repository.
|
"""List available backup sets for an installed repository.
|
||||||
|
|
||||||
@@ -1438,6 +1536,30 @@ class BCSCore:
|
|||||||
domains of the repository.
|
domains of the repository.
|
||||||
"""
|
"""
|
||||||
inst = self.get_installed(repo_id) or {}
|
inst = self.get_installed(repo_id) or {}
|
||||||
|
install_type = str(inst.get("install_type") or "integration").strip() or "integration"
|
||||||
|
if install_type == "blueprint":
|
||||||
|
repo_root = self._backup_root / "_content" / self._backup_repo_key(repo_id)
|
||||||
|
|
||||||
|
def _list_content() -> list[str]:
|
||||||
|
if not repo_root.exists() or not repo_root.is_dir():
|
||||||
|
return []
|
||||||
|
ids = [p.name for p in repo_root.iterdir() if p.is_dir()]
|
||||||
|
ids.sort(reverse=True)
|
||||||
|
return ids
|
||||||
|
|
||||||
|
ids = await self.hass.async_add_executor_job(_list_content)
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for bid in ids[: self._backup_keep_per_domain]:
|
||||||
|
label = self._format_backup_id(bid)
|
||||||
|
meta = await self._read_content_backup_meta(repo_id, bid)
|
||||||
|
ver = None
|
||||||
|
if isinstance(meta, dict):
|
||||||
|
ver = meta.get("installed_version") or meta.get("ref")
|
||||||
|
if ver:
|
||||||
|
label = f"{label} ({ver})"
|
||||||
|
items.append({"id": bid, "label": label, "complete": True, "domains": [], "installed_version": str(ver) if ver else None})
|
||||||
|
return items
|
||||||
|
|
||||||
domains = inst.get("domains") or []
|
domains = inst.get("domains") or []
|
||||||
if not isinstance(domains, list) or not domains:
|
if not isinstance(domains, list) or not domains:
|
||||||
return []
|
return []
|
||||||
@@ -1489,6 +1611,53 @@ class BCSCore:
|
|||||||
if not inst:
|
if not inst:
|
||||||
raise BCSInstallError("Repository is not installed")
|
raise BCSInstallError("Repository is not installed")
|
||||||
|
|
||||||
|
install_type = str(getattr(inst, "install_type", "integration") or "integration").strip() or "integration"
|
||||||
|
if install_type == "blueprint":
|
||||||
|
backup_path = self._backup_root / "_content" / self._backup_repo_key(repo_id) / backup_id
|
||||||
|
if not backup_path.exists() or not backup_path.is_dir():
|
||||||
|
raise BCSInstallError("Selected backup is not available")
|
||||||
|
|
||||||
|
installed_paths = [str(p).strip() for p in (getattr(inst, "installed_paths", None) or []) if str(p).strip()]
|
||||||
|
|
||||||
|
async with self._install_lock:
|
||||||
|
_LOGGER.info("BCS restore started: repo_id=%s backup_id=%s type=blueprint", repo_id, backup_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._backup_paths(repo_id, installed_paths)
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.debug("BCS pre-restore content backup failed for repo_id=%s", repo_id, exc_info=True)
|
||||||
|
|
||||||
|
await self._restore_paths_from_backup(backup_path, remove_targets=installed_paths)
|
||||||
|
|
||||||
|
restored_meta = await self._read_content_backup_meta(repo_id, backup_id)
|
||||||
|
restored_version: str | None = None
|
||||||
|
if isinstance(restored_meta, dict):
|
||||||
|
rv = restored_meta.get("installed_version") or restored_meta.get("ref")
|
||||||
|
if rv is not None and str(rv).strip():
|
||||||
|
restored_version = str(rv).strip()
|
||||||
|
if not restored_version:
|
||||||
|
restored_version = f"restored:{backup_id}"
|
||||||
|
|
||||||
|
repo = self.get_repo(repo_id)
|
||||||
|
repo_url = getattr(repo, "url", None) or ""
|
||||||
|
|
||||||
|
await self.storage.set_installed_repo(
|
||||||
|
repo_id=repo_id,
|
||||||
|
url=repo_url,
|
||||||
|
domains=[],
|
||||||
|
installed_version=restored_version,
|
||||||
|
installed_manifest_version=None,
|
||||||
|
ref=restored_version,
|
||||||
|
install_type="blueprint",
|
||||||
|
installed_paths=installed_paths,
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._refresh_installed_cache()
|
||||||
|
self.signal_updated()
|
||||||
|
|
||||||
|
_LOGGER.info("BCS restore complete: repo_id=%s backup_id=%s type=blueprint", repo_id, backup_id)
|
||||||
|
return {"ok": True, "repo_id": repo_id, "backup_id": backup_id, "domains": [], "installed_paths": installed_paths, "restored_version": restored_version, "restart_required": False}
|
||||||
|
|
||||||
domains = inst.get("domains") or []
|
domains = inst.get("domains") or []
|
||||||
if not isinstance(domains, list) or not domains:
|
if not isinstance(domains, list) or not domains:
|
||||||
raise BCSInstallError("No installed domains found")
|
raise BCSInstallError("No installed domains found")
|
||||||
@@ -1585,6 +1754,17 @@ class BCSCore:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def _read_content_backup_meta(self, repo_id: str, backup_id: str) -> dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
p = self._backup_root / "_content" / self._backup_repo_key(repo_id) / backup_id / BACKUP_META_FILENAME
|
||||||
|
if not p.exists():
|
||||||
|
return None
|
||||||
|
txt = await self.hass.async_add_executor_job(p.read_text, 'utf-8')
|
||||||
|
data = json.loads(txt)
|
||||||
|
return data if isinstance(data, dict) else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _read_backup_manifest_version(self, domain: str, backup_id: str) -> str | None:
|
async def _read_backup_manifest_version(self, domain: str, backup_id: str) -> str | None:
|
||||||
"""Best-effort: read manifest.json version from a legacy backup (no metadata)."""
|
"""Best-effort: read manifest.json version from a legacy backup (no metadata)."""
|
||||||
@@ -1806,6 +1986,7 @@ class BCSCore:
|
|||||||
installed_domains: list[str] = []
|
installed_domains: list[str] = []
|
||||||
installed_paths: list[str] = []
|
installed_paths: list[str] = []
|
||||||
backups: dict[str, Path] = {}
|
backups: dict[str, Path] = {}
|
||||||
|
content_backup: Path | None = None
|
||||||
install_type = self._repo_install_type(repo)
|
install_type = self._repo_install_type(repo)
|
||||||
|
|
||||||
inst_before = self.get_installed(repo_id) or {}
|
inst_before = self.get_installed(repo_id) or {}
|
||||||
@@ -1832,8 +2013,22 @@ class BCSCore:
|
|||||||
if not blueprints_root:
|
if not blueprints_root:
|
||||||
raise BCSInstallError("blueprints folder not found in repository ZIP")
|
raise BCSInstallError("blueprints folder not found in repository ZIP")
|
||||||
|
|
||||||
config_root = Path(self.hass.config.path(""))
|
|
||||||
target_root = Path(self.hass.config.path("blueprints"))
|
target_root = Path(self.hass.config.path("blueprints"))
|
||||||
|
planned_paths: list[str] = []
|
||||||
|
|
||||||
|
for src in blueprints_root.rglob("*"):
|
||||||
|
if not src.is_file():
|
||||||
|
continue
|
||||||
|
rel = src.relative_to(blueprints_root)
|
||||||
|
planned_paths.append(str(Path("blueprints") / rel).replace("\\", "/"))
|
||||||
|
|
||||||
|
if not planned_paths:
|
||||||
|
raise BCSInstallError("No blueprint files found under blueprints/")
|
||||||
|
|
||||||
|
m = dict(backup_meta)
|
||||||
|
m["install_type"] = "blueprint"
|
||||||
|
m["installed_paths"] = planned_paths
|
||||||
|
content_backup = await self._backup_paths(repo_id, planned_paths, meta=m)
|
||||||
|
|
||||||
def _copy_blueprints() -> list[str]:
|
def _copy_blueprints() -> list[str]:
|
||||||
copied: list[str] = []
|
copied: list[str] = []
|
||||||
@@ -1849,8 +2044,6 @@ class BCSCore:
|
|||||||
return copied
|
return copied
|
||||||
|
|
||||||
installed_paths = await self.hass.async_add_executor_job(_copy_blueprints)
|
installed_paths = await self.hass.async_add_executor_job(_copy_blueprints)
|
||||||
if not installed_paths:
|
|
||||||
raise BCSInstallError("No blueprint files found under blueprints/")
|
|
||||||
else:
|
else:
|
||||||
cc_root = self._find_custom_components_root(extract_dir)
|
cc_root = self._find_custom_components_root(extract_dir)
|
||||||
if not cc_root:
|
if not cc_root:
|
||||||
@@ -1934,6 +2127,12 @@ class BCSCore:
|
|||||||
except Exception:
|
except Exception:
|
||||||
_LOGGER.debug("BCS rollback failed for domain=%s", domain, exc_info=True)
|
_LOGGER.debug("BCS rollback failed for domain=%s", domain, exc_info=True)
|
||||||
|
|
||||||
|
if content_backup is not None:
|
||||||
|
try:
|
||||||
|
await self._restore_paths_from_backup(content_backup, remove_targets=installed_paths)
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.debug("BCS content rollback failed for repo_id=%s", repo_id, exc_info=True)
|
||||||
|
|
||||||
# Remove newly created domains if the install did not complete.
|
# Remove newly created domains if the install did not complete.
|
||||||
for domain in created_new:
|
for domain in created_new:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "bahmcloud_store",
|
"domain": "bahmcloud_store",
|
||||||
"name": "Bahmcloud Store",
|
"name": "Bahmcloud Store",
|
||||||
"version": "0.7.5",
|
"version": "0.7.6",
|
||||||
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
|
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"platforms": ["update"],
|
"platforms": ["update"],
|
||||||
|
|||||||
@@ -206,7 +206,9 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
this._error = this._safeText(resp?.message) || "Install failed.";
|
this._error = this._safeText(resp?.message) || "Install failed.";
|
||||||
} else {
|
} else {
|
||||||
this._restartRequired = !!resp.restart_required;
|
this._restartRequired = !!resp.restart_required;
|
||||||
this._lastActionMsg = "Installation finished. Restart required.";
|
this._lastActionMsg = resp?.restart_required
|
||||||
|
? "Installation finished. Restart required."
|
||||||
|
: "Installation finished. No restart required.";
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this._error = e?.message ? String(e.message) : String(e);
|
this._error = e?.message ? String(e.message) : String(e);
|
||||||
@@ -238,7 +240,9 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
this._error = this._safeText(resp?.message) || "Update failed.";
|
this._error = this._safeText(resp?.message) || "Update failed.";
|
||||||
} else {
|
} else {
|
||||||
this._restartRequired = !!resp.restart_required;
|
this._restartRequired = !!resp.restart_required;
|
||||||
this._lastActionMsg = "Update finished. Restart required.";
|
this._lastActionMsg = resp?.restart_required
|
||||||
|
? "Update finished. Restart required."
|
||||||
|
: "Update finished. No restart required.";
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this._error = e?.message ? String(e.message) : String(e);
|
this._error = e?.message ? String(e.message) : String(e);
|
||||||
@@ -253,7 +257,11 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
if (!repoId) return;
|
if (!repoId) return;
|
||||||
if (this._installingRepoId || this._updatingRepoId || this._uninstallingRepoId) return;
|
if (this._installingRepoId || this._updatingRepoId || this._uninstallingRepoId) return;
|
||||||
|
|
||||||
const ok = window.confirm("Really uninstall this repository? This will remove its files from /config/custom_components and requires a restart.");
|
const details = this._getInstallTypeDetails(repoId);
|
||||||
|
const uninstallText = details.restartRequired
|
||||||
|
? `Really uninstall this repository? This will remove its files from ${details.targetPath} and requires a restart.`
|
||||||
|
: `Really uninstall this repository? This will remove its files from ${details.targetPath}. No restart is required.`;
|
||||||
|
const ok = window.confirm(uninstallText);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
this._uninstallingRepoId = repoId;
|
this._uninstallingRepoId = repoId;
|
||||||
@@ -267,7 +275,9 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
this._error = this._safeText(resp?.message) || "Uninstall failed.";
|
this._error = this._safeText(resp?.message) || "Uninstall failed.";
|
||||||
} else {
|
} else {
|
||||||
this._restartRequired = !!resp.restart_required;
|
this._restartRequired = !!resp.restart_required;
|
||||||
this._lastActionMsg = "Uninstall finished. Restart required.";
|
this._lastActionMsg = resp?.restart_required
|
||||||
|
? "Uninstall finished. Restart required."
|
||||||
|
: "Uninstall finished. No restart required.";
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this._error = e?.message ? String(e.message) : String(e);
|
this._error = e?.message ? String(e.message) : String(e);
|
||||||
@@ -337,7 +347,11 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ok = window.confirm("Restore selected backup? This will overwrite the installed files under /config/custom_components and requires a restart.");
|
const details = this._getInstallTypeDetails(this._restoreRepoId);
|
||||||
|
const restoreText = details.restartRequired
|
||||||
|
? `Restore selected backup? This will overwrite the installed files under ${details.targetPath} and requires a restart.`
|
||||||
|
: `Restore selected backup? This will overwrite the installed files under ${details.targetPath}. No restart is required.`;
|
||||||
|
const ok = window.confirm(restoreText);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
this._restoring = true;
|
this._restoring = true;
|
||||||
@@ -350,7 +364,9 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
this._restoreError = this._safeText(resp?.message) || "Restore failed.";
|
this._restoreError = this._safeText(resp?.message) || "Restore failed.";
|
||||||
} else {
|
} else {
|
||||||
this._restartRequired = !!resp.restart_required;
|
this._restartRequired = !!resp.restart_required;
|
||||||
this._lastActionMsg = "Restore finished. Restart required.";
|
this._lastActionMsg = resp?.restart_required
|
||||||
|
? "Restore finished. Restart required."
|
||||||
|
: "Restore finished. No restart required.";
|
||||||
this._closeRestore();
|
this._closeRestore();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -981,6 +997,27 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
return !!id && Array.isArray(this._favoriteRepoIds) && this._favoriteRepoIds.includes(id);
|
return !!id && Array.isArray(this._favoriteRepoIds) && this._favoriteRepoIds.includes(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_getRepoById(repoId) {
|
||||||
|
const id = this._safeId(repoId);
|
||||||
|
if (!id || !Array.isArray(this._data?.repos)) return null;
|
||||||
|
return this._data.repos.find((r) => this._safeId(r?.id) === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_getInstallTypeDetails(repoOrId) {
|
||||||
|
const repo = typeof repoOrId === "string" ? this._getRepoById(repoOrId) : repoOrId;
|
||||||
|
const installType = this._safeText(repo?.install_type) || "integration";
|
||||||
|
if (installType === "blueprint") {
|
||||||
|
return { installType, targetPath: "/config/blueprints", restartRequired: false };
|
||||||
|
}
|
||||||
|
if (installType === "template") {
|
||||||
|
return { installType, targetPath: "/config", restartRequired: false };
|
||||||
|
}
|
||||||
|
if (installType === "lovelace") {
|
||||||
|
return { installType, targetPath: "/config/www", restartRequired: false };
|
||||||
|
}
|
||||||
|
return { installType: "integration", targetPath: "/config/custom_components", restartRequired: true };
|
||||||
|
}
|
||||||
|
|
||||||
async _toggleFavorite(repoId) {
|
async _toggleFavorite(repoId) {
|
||||||
if (!this._hass || !repoId) return;
|
if (!this._hass || !repoId) return;
|
||||||
|
|
||||||
@@ -1379,9 +1416,7 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
const installBtn = `<button class="primary" id="btnInstall" ${installed || busy ? "disabled" : ""}>${busyInstall ? "Installing…" : installed ? "Installed" : "Install"}</button>`;
|
const installBtn = `<button class="primary" id="btnInstall" ${installed || busy ? "disabled" : ""}>${busyInstall ? "Installing…" : installed ? "Installed" : "Install"}</button>`;
|
||||||
const updateBtn = `<button class="primary" id="btnUpdate" ${!updateAvailable || busy ? "disabled" : ""}>${busyUpdate ? "Updating…" : updateAvailable ? "Update" : "Up to date"}</button>`;
|
const updateBtn = `<button class="primary" id="btnUpdate" ${!updateAvailable || busy ? "disabled" : ""}>${busyUpdate ? "Updating…" : updateAvailable ? "Update" : "Up to date"}</button>`;
|
||||||
const uninstallBtn = `<button class="primary" id="btnUninstall" ${!installed || busy ? "disabled" : ""}>${busyUninstall ? "Uninstalling…" : "Uninstall"}</button>`;
|
const uninstallBtn = `<button class="primary" id="btnUninstall" ${!installed || busy ? "disabled" : ""}>${busyUninstall ? "Uninstalling…" : "Uninstall"}</button>`;
|
||||||
const restoreBtn = installType === "integration"
|
const restoreBtn = `<button class="primary" id="btnRestore" ${!installed || busy ? "disabled" : ""}>Restore</button>`;
|
||||||
? `<button class="primary" id="btnRestore" ${!installed || busy ? "disabled" : ""}>Restore</button>`
|
|
||||||
: ``;
|
|
||||||
const favoriteBtn = `<button id="btnFavorite">${favorite ? "Unpin" : "Pin"}</button>`;
|
const favoriteBtn = `<button id="btnFavorite">${favorite ? "Unpin" : "Pin"}</button>`;
|
||||||
|
|
||||||
const restartHint = this._restartRequired
|
const restartHint = this._restartRequired
|
||||||
@@ -1628,12 +1663,16 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
|
const restoreDetails = this._getInstallTypeDetails(this._restoreRepoId);
|
||||||
|
const actionHint = restoreDetails.restartRequired
|
||||||
|
? `Select a backup to restore. This will overwrite files under ${restoreDetails.targetPath} and requires a restart.`
|
||||||
|
: `Select a backup to restore. This will overwrite files under ${restoreDetails.targetPath}. No restart is required.`;
|
||||||
const msg = this._restoreLoading
|
const msg = this._restoreLoading
|
||||||
? "Loading backups…"
|
? "Loading backups…"
|
||||||
: this._restoreError
|
: this._restoreError
|
||||||
? this._safeText(this._restoreError)
|
? this._safeText(this._restoreError)
|
||||||
: opts.length
|
: opts.length
|
||||||
? "Select a backup to restore. This will overwrite files under /config/custom_components and requires a restart."
|
? actionHint
|
||||||
: "No backups found.";
|
: "No backups found.";
|
||||||
|
|
||||||
return `
|
return `
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -9,6 +10,7 @@ from homeassistant.core import HomeAssistant, callback
|
|||||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.entity import EntityCategory
|
from homeassistant.helpers.entity import EntityCategory
|
||||||
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .core import SIGNAL_UPDATED, BCSCore
|
from .core import SIGNAL_UPDATED, BCSCore
|
||||||
@@ -127,8 +129,36 @@ class BCSRepoUpdateEntity(UpdateEntity):
|
|||||||
def _sync_entities(core: BCSCore, existing: dict[str, BCSRepoUpdateEntity], async_add_entities: AddEntitiesCallback) -> None:
|
def _sync_entities(core: BCSCore, existing: dict[str, BCSRepoUpdateEntity], async_add_entities: AddEntitiesCallback) -> None:
|
||||||
"""Ensure there is one update entity per installed repo AND keep names in sync."""
|
"""Ensure there is one update entity per installed repo AND keep names in sync."""
|
||||||
installed_map = getattr(core, "_installed_cache", {}) or {}
|
installed_map = getattr(core, "_installed_cache", {}) or {}
|
||||||
|
installed_repo_ids = {repo_id for repo_id, data in installed_map.items() if isinstance(data, dict)}
|
||||||
new_entities: list[BCSRepoUpdateEntity] = []
|
new_entities: list[BCSRepoUpdateEntity] = []
|
||||||
|
|
||||||
|
stale_repo_ids = [repo_id for repo_id in list(existing) if repo_id not in installed_repo_ids]
|
||||||
|
if stale_repo_ids:
|
||||||
|
registry = er.async_get(core.hass)
|
||||||
|
for repo_id in stale_repo_ids:
|
||||||
|
ent = existing.pop(repo_id, None)
|
||||||
|
if not ent:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
entity_id = getattr(ent, "entity_id", None)
|
||||||
|
if not entity_id:
|
||||||
|
for entry in registry.entities.values():
|
||||||
|
if getattr(entry, "platform", None) == DOMAIN and getattr(entry, "unique_id", None) == ent.unique_id:
|
||||||
|
entity_id = entry.entity_id
|
||||||
|
break
|
||||||
|
if entity_id:
|
||||||
|
registry.async_remove(entity_id)
|
||||||
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||||
|
_LOGGER.debug("BCS could not remove stale update entity for %s: %s", repo_id, exc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = ent.async_remove()
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
core.hass.async_create_task(result)
|
||||||
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||||
|
_LOGGER.debug("BCS could not detach stale update entity for %s: %s", repo_id, exc)
|
||||||
|
|
||||||
for repo_id, data in installed_map.items():
|
for repo_id, data in installed_map.items():
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
continue
|
continue
|
||||||
@@ -178,4 +208,4 @@ async def async_setup_entry(
|
|||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up BCS update entities from a config entry."""
|
"""Set up BCS update entities from a config entry."""
|
||||||
await async_setup_platform(hass, {}, async_add_entities, None)
|
await async_setup_platform(hass, {}, async_add_entities, None)
|
||||||
|
|||||||
Reference in New Issue
Block a user