36 Commits
0.6.8 ... main

Author SHA1 Message Date
599b8e0b7d Remove stale update entities after uninstall 2026-03-23 17:50:41 +01:00
313fcf94e7 Bump version to 0.7.6 2026-03-23 17:20:29 +01:00
d976ab56e3 Fix blueprint uninstall and restore messaging 2026-03-23 17:14:22 +01:00
80c1c2966f Restore blueprint backup restore support 2026-03-23 17:04:08 +01:00
de3fbf1a12 Fix blueprint update backups 2026-03-23 16:58:38 +01:00
a55281938c Add blueprint support and refresh documentation 2026-03-23 16:42:52 +01:00
48f8ef6265 Prepare blueprint install support 2026-03-23 16:31:16 +01:00
9448176ff4 Bump version to 0.7.4 2026-03-23 15:46:34 +01:00
a029738ec8 Add pinned repositories 2026-03-23 15:22:53 +01:00
754540d578 CHANGELOG.md aktualisiert 2026-03-23 13:48:54 +00:00
1103c5e326 Bump version to 0.7.3 2026-03-23 14:47:13 +01:00
b7d4d0ded4 Add release notes and project prompts 2026-03-23 14:09:29 +01:00
a8ff892993 add 0.7.2 2026-01-20 08:15:43 +00:00
90223e3fc4 0.7.2 2026-01-20 08:15:14 +00:00
0f5504b67d 0.7.2 2026-01-20 08:14:58 +00:00
5fff1b2692 add 0.7.1 2026-01-20 07:19:03 +00:00
c8356c7603 0.7.1 2026-01-20 07:17:45 +00:00
0c49a50fc9 0.7.1 2026-01-20 07:17:24 +00:00
fa48841645 0.7.1 2026-01-20 07:16:48 +00:00
1445fff739 add 0.7.0 2026-01-20 05:50:18 +00:00
5cf365f354 0.7 0 2026-01-20 05:48:27 +00:00
f73ce4095c 0.7.0 2026-01-20 05:47:49 +00:00
1484d53f8c 0.7.0 2026-01-20 05:47:00 +00:00
0e99c9c59e 0.7.0 2026-01-20 05:46:20 +00:00
644e61aab0 0.7.0 2026-01-20 05:45:41 +00:00
4c2a104af7 0.7.0 2026-01-20 05:45:02 +00:00
95dd8b9dc2 0.7.0 2026-01-20 05:44:32 +00:00
8b01c04a4c 0 7.0 2026-01-20 05:43:57 +00:00
981f56a693 add 0.6.9 2026-01-19 17:51:36 +00:00
f6bd04f354 0.6.9 2026-01-19 17:42:35 +00:00
8da8a26a90 0.6.9 2026-01-19 17:41:41 +00:00
42fe5afe52 0.6.9 2026-01-19 17:41:18 +00:00
437c020566 0.6.9 2026-01-19 17:40:27 +00:00
b863ed4d51 0.6.9 2026-01-19 17:39:37 +00:00
368642345d Add on 0.6.9 2026-01-19 17:39:09 +00:00
43bc31c8b4 0.6.9 2026-01-19 16:52:59 +00:00
21 changed files with 1740 additions and 338 deletions

3
.gitignore vendored
View File

@@ -174,3 +174,6 @@ cython_debug/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
# Local project instructions
.idea/start prompt.md

10
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

8
.idea/Projects.iml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

57
.idea/changes.md generated Normal file
View File

@@ -0,0 +1,57 @@
# Changes Log
## 2026-03-23
### Added
- Created `.idea/start prompt.md` as a persistent project working prompt for future sessions.
- Added `.idea/changes.md` as the preferred in-project location for the detailed session change log.
- Added release notes support in the active Bahmcloud Store runtime path: backend provider fetching, a dedicated API endpoint, and panel display for the currently selected version when provider release notes are available.
- Bumped the Home Assistant panel asset cache-buster from `v=109` to `v=110` so the updated frontend loads reliably after deployment.
- Bumped the integration version from `0.7.2` to `0.7.3`.
- Added the `0.7.3` release entry to `CHANGELOG.md` with the new release-notes and project-workflow changes.
- Added a persistent rule that commit messages, pushes, release notes, and changelog entries must never mention prompts, AI/KI tools, or `.idea` folder files.
- Added a persistent release rule that future git tags and release names must use plain version numbers without a leading `v`.
- Added a broader persistent rule that internal workflow or prompt/process decisions must never appear in any public-facing project communication.
- Added a persistent language rule that all project-facing repository content must stay in English regardless of the chat language.
- Added persistent pinned repositories support: favorites are stored in settings, exposed by the backend, filterable and sortable in the store view, and toggleable from the detail view without forcing a full repository refresh.
- Bumped the integration version from `0.7.3` to `0.7.4` and added the `0.7.4` release entry to `CHANGELOG.md` for the pinned-repositories feature.
- Added the broader product roadmap to the persistent project prompt: blueprints, templates, Lovelace designs, and more category support as future store targets.
- 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/`.
- 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
- 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.
- Recorded the actual active architecture from source analysis, including the config-entry-only setup, the fixed Bahmcloud store index, delayed startup refresh, periodic refresh, repo merge flow, cache usage, install/update/uninstall pipeline, backup/restore pipeline, update entities, and Repairs-based restart handling.
- Recorded the current provider reality from code: GitHub, GitLab, and Gitea-compatible repositories are the concrete supported paths today, while truly generic "all git providers" support is still an intention and must be validated case by case.
- Recorded the public API endpoints exposed by `views.py` so future work preserves the current backend contract unless a deliberate breaking change is approved.
- Recorded storage facts from `storage.py`, including the `bcs_store` Home Assistant storage key and the persisted sections for custom repositories, installed repositories, settings, HACS cache, and repo enrichment cache.
- Recorded frontend facts from the active panel registration in `__init__.py` and the active frontend implementation in `panel/panel.js`, including the cache-busting panel asset version query.
- Updated the persistent start prompt to point future work to `.idea/changes.md` as the canonical detailed work log.
- Release notes are intentionally tied to provider release objects, so tags or branches without release bodies now return a clear "not available" state instead of misleading fallback text.
### Important findings from code analysis
- Identified `custom_components/bahmcloud_store/panel/panel.js` as the active Home Assistant panel script currently loaded by the integration.
- Identified `custom_components/bahmcloud_store/panel/app.js`, `custom_components/bahmcloud_store/panel/index.html`, and `custom_components/bahmcloud_store/panel/styles.css` as likely legacy or secondary assets that should not be treated as authoritative without verification.
- Identified `custom_components/bahmcloud_store/store.py` as an older implementation with a different data model and API shape than the active `BCSCore` runtime.
- Identified `custom_components/bahmcloud_store/custom_repo_view.py` as duplicate or legacy API code because the active custom-repo handling already exists in `views.py`.
- Noted that the README set is directionally useful but not fully authoritative where it conflicts with current code behavior.
- Noted that some repository files contain encoding or mojibake artifacts, so future edits should preserve valid UTF-8 and avoid spreading broken text.
### Project rules written into the start prompt
- Never push, commit, tag, or create a release without explicit user approval.
- Always append a dated and detailed entry to `.idea/changes.md` for every change made.
- When a release is created, collect all relevant changes since the last release into `CHANGELOG.md`.
### Verification
- Reviewed repository structure and current git status.
- Read `README.md`, `README_DEVELOPER.md`, `README_FULL.md`, `bcs.yaml`, and the current `CHANGELOG.md`.
- Analyzed the active backend files: `__init__.py`, `const.py`, `core.py`, `providers.py`, `metadata.py`, `storage.py`, `views.py`, `config_flow.py`, `update.py`, and `repairs.py`.
- Checked panel and legacy-related files to distinguish the currently active UI path from older or duplicated files.
- Verified that the active panel (`panel/panel.js`) now requests release notes from the new backend route and reloads them when the selected install version changes.

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Projects.iml" filepath="$PROJECT_DIR$/.idea/Projects.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@@ -11,6 +11,98 @@ 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
### Added
- Initial blueprint install support with deployment to the Home Assistant blueprints folder.
- Category-aware install metadata including install type, install target, and installed paths.
- Install target details in the repository view for supported content categories.
### Changed
- Store documentation refreshed to match the current feature set, including pinned repositories, release notes, and blueprint support.
- Non-integration content handling is now prepared for category-specific install strategies instead of assuming `custom_components` only.
- Frontend asset version updated so the latest store UI changes load immediately after deployment.
## 0.7.4 - 2026-03-23
### Added
- Pinned repositories support to let admins keep important integrations easy to find.
- Pin and unpin actions in the repository detail view.
- Store filtering and sorting options for pinned repositories.
### Changed
- Repository favorites are now persisted in settings and restored across reloads.
- Pinned repositories are highlighted in the store overview with a dedicated badge.
- Frontend asset version updated so the latest panel changes load immediately after deployment.
## 0.7.3 - 2026-03-23
### Added
- Release notes support in the repository detail view for provider releases.
- New backend endpoint to fetch release notes for the selected repository version.
### Changed
- Home Assistant panel asset cache-buster bumped to ensure updated frontend assets load after deployment.
- Release-note lookups now clearly report when a selected version has no provider release body instead of implying notes exist for plain tags or branches.
## 0.7.2 2026-01-20
### Fixed
- When Bahmcloud Store is installed via an external installer (files copied into /config/custom_components), it now reconciles itself as "installed" in BCS storage so update checks work immediately.
## 0.7.1 2026-01-20
### Fixed
- GitHub version provider now reliably fetches the latest 20 releases/tags using authenticated API requests.
- Repositories that were previously fetched in a degraded state (only `latest` and `branch`) are now automatically refreshed on repository view.
- Cached version lists with incomplete data are no longer reused and are re-fetched from the provider.
## [0.7.0] - 2026-01-20
### Added
- Options dialog (gear icon) for the Bahmcloud Store integration.
- Optional GitHub token can now be set, changed or removed via the Home Assistant UI.
### Fixed
- Fixed missing options flow when clicking the integration settings button.
## [0.6.9] 2026-01-19
### Added
- New Home Assistant **GUI setup** (Config Flow) no YAML configuration required.
- Optional **GitHub Token** support to increase API limits (up to 5000 req/h).
Configurable via *Integration → Options*.
- Clear setup guidance and warning about GitHub rate limits.
- Automatic detection and warning if YAML setup is still present (ignored safely).
### Changed
- **store.yaml** URL is now fixed to the official Bahmcloud Store index:
https://git.bahmcloud.de/bahmcloud/ha_store/raw/branch/main/store.yaml
- Installation workflow fully aligned with standard HA integrations.
- Update platform migrated to `async_setup_entry`.
### Fixed
- Minor stability and persistence improvements in startup sequence.
- Prevented duplicate background initialization when HA reloads the integration.
### Notes
- To enable extended GitHub access, create a fine-grained personal access token
(read-only) at https://github.com/settings/tokens and add it via the integration options.
## 0.6.8 Performance & Cache Stabilization (2026-01-19) ## 0.6.8 Performance & Cache Stabilization (2026-01-19)
### Fixed ### Fixed

View File

@@ -1,50 +1,86 @@
# Bahmcloud Store (BCS) for Home Assistant # Bahmcloud Store (BCS) for Home Assistant
Bahmcloud Store (BCS) is a provider-neutral store for Home Assistant custom integrations. Bahmcloud Store (BCS) is a provider-neutral store for Home Assistant content hosted on git platforms.
It allows you to browse, install, update, downgrade, uninstall and restore integrations It currently supports installing and managing:
directly from the Home Assistant UI.
This README is intended for **end users**. - Integrations
- Blueprints
Supported providers today:
- GitHub
- GitLab
- Gitea / Forgejo-style hosts
- Custom repositories added by URL when the host behaves compatibly
This README is for end users.
---
## Features
- Browse repositories from the official BCS index, HACS integration source, and custom repository URLs
- Install, update, downgrade, and uninstall integrations directly from Home Assistant
- Install blueprint repositories directly into the Home Assistant blueprints folder
- Read repository README files inside the store UI
- View release notes for provider releases
- Pin repositories so important items stay easy to find
- Use native Home Assistant update entities for installed integrations
- Create and restore backups for integration installs and updates
- Create and restore blueprint content backups during blueprint updates and restore actions
--- ---
## Installation ## Installation
### Option A: Home Assistant OS / Supervised (Add-on recommended) ### Option A: Home Assistant OS / Supervised
1. Open **Settings Add-ons Add-on Store** 1. Open **Settings -> Add-ons -> Add-on Store**
2. Add the Bahmcloud Add-on repository 2. Add the Bahmcloud add-on repository:
`https://git.bahmcloud.de/bahmcloud/addons` `https://git.bahmcloud.de/bahmcloud/addons`
3. Install **Bahmcloud Store Installer** 3. Install **Bahmcloud Store Installer**
4. Start the add-on 4. Start the add-on
5. Restart Home Assistant 5. Restart Home Assistant
Installed to: Installed to:
/config/custom_components/bahmcloud_store `/config/custom_components/bahmcloud_store`
--- ### Option B: Manual Installation
### Option B: Manual Installation (Container / Core)
1. Download the latest release 1. Download the latest release
2. Copy `custom_components/bahmcloud_store` to: 2. Copy `custom_components/bahmcloud_store` to:
/config/custom_components/bahmcloud_store `/config/custom_components/bahmcloud_store`
3. Restart Home Assistant 3. Restart Home Assistant
--- ---
## Repository Sources ## Repository Sources
- **BCS Official** - **BCS Official**: repositories from the Bahmcloud store index
- **HACS** - **HACS**: optional official HACS integration source
- **Custom** - **Custom**: repositories you add manually by URL
---
## Current Content Support
### Integrations
- Expected repository content: `custom_components/<domain>/...`
- Install target: `/config/custom_components/<domain>`
- Supports install, update, uninstall, backup, restore, version selection, and update entities
- Restart required after install, update, uninstall, or restore
### Blueprints
- Expected repository content: `blueprints/...`
- Install target: `/config/blueprints/...`
- Supports install, update, uninstall, backup, and restore through the store
- No restart is normally required for blueprint deployment
--- ---
## Documentation ## Documentation
Developer documentation: - Developer documentation: `README_DEVELOPER.md`
https://git.bahmcloud.de/bahmcloud/bahmcloud_store/src/branch/main/README_DEVELOPER.md - Full user guide: `README_FULL.md`
Full technical documentation:
https://git.bahmcloud.de/bahmcloud/bahmcloud_store/src/branch/main/README_FULL.md

View File

@@ -1,115 +1,159 @@
# Bahmcloud Store Developer Documentation # Bahmcloud Store - Developer Documentation
For contributors and maintainers. For contributors and maintainers.
## Architecture ## Project Scope
Repositories: Bahmcloud Store is evolving from an integration-only store into a broader Home Assistant git-based content store.
1) Installer Add-on (HAOS/Supervised) ```https://git.bahmcloud.de/bahmcloud/addons```
2) Core Integration ```https://git.bahmcloud.de/bahmcloud/bahmcloud_store```
3) Store Index (`store.yaml`) ```https://git.bahmcloud.de/bahmcloud/ha_store```
### Integration Layout Currently supported install categories:
custom_components/bahmcloud_store/ - Integrations
- __init__.py: setup, panel registration, schedule background after HA started - Blueprints
- core.py: index merge, enrichment, install/update/uninstall, backups, restore, caching
- providers.py: GitHub/GitLab/Gitea repo info + latest version helpers Planned categories:
- metadata.py: read bcs.yaml / hacs.json / hacs.yaml
- storage.py: persistent storage (installed, custom, repo cache, hacs cache) - Templates
- views.py: HTTP API endpoints - Lovelace / dashboard designs
- update.py: UpdateEntity implementation - Additional content types with category-specific install logic
- repairs.py: (optional) Repairs flow for restart
- panel/: UI (panel.js, styles.css, etc.) ## Repository Layout
- manifest.json
Repositories related to the project:
1. Installer add-on:
`https://git.bahmcloud.de/bahmcloud/addons`
2. Core integration:
`https://git.bahmcloud.de/bahmcloud/bahmcloud_store`
3. Store index:
`https://git.bahmcloud.de/bahmcloud/ha_store`
## Integration Layout
`custom_components/bahmcloud_store/`
- `__init__.py`: setup, panel registration, delayed startup refresh, periodic refresh
- `core.py`: index merge, repo enrichment, install/update/uninstall, backup/restore, category-aware install groundwork
- `providers.py`: provider detection, latest version lookup, release notes, README fetching
- `metadata.py`: reads `bcs.yaml`, `hacs.json`, and `hacs.yaml`
- `storage.py`: persistent storage for installed repos, settings, caches
- `views.py`: HTTP API
- `update.py`: Home Assistant update entities
- `repairs.py`: restart-required repair flow
- `panel/`: active frontend panel
- `manifest.json`
## Runtime Model ## Runtime Model
- RepoItem (merged) - `RepoItem`: merged repository model used by the UI and backend
- Installed repos (storage) - Installed repositories: persisted in Home Assistant storage
- Repo cache (persisted enrichment) - Settings: persistent UI and behavior settings such as HACS enablement and pinned repositories
- HACS meta cache (mapping owner/repo → name/description) - Repo cache: provider and metadata enrichment cache
- HACS cache: display metadata cache for HACS integration repositories
## Background
- Heavy work only after `homeassistant_started`
- Refresh: if index unchanged → installed-only refresh + schedule enrichment
- Opening a repo triggers `ensure_repo_details()` and persists to cache
## Providers
- GitHub: API/releases/tags/atom + raw readme
- GitLab: API releases/tags + raw readme
- Gitea: API releases/tags + raw readme
- Custom: API or HTTPS Request to your Git Provider
## Metadata ## Metadata
- Prefer `bcs.yaml`, fallback `hacs.json` / `hacs.yaml` Metadata priority:
- Populate name/description/category/author/maintainer
## HTTP API (excerpt) 1. `bcs.yaml`
2. `hacs.yaml`
3. `hacs.json`
Base: /api/bcs Common fields:
- GET /api/bcs
- POST /api/bcs?action=refresh
- GET /api/bcs/readme?repo_id=...
- GET /api/bcs/versions?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=...
- (optional) POST/DELETE custom_repo
## Update Entities
- Unique id bcs:<repo_id>
- Compare installed ref vs latest ref
- Dispatcher signal on refresh/install/update
## Storage
- JSON in HA `.storage`
- async read/write helpers
- repo cache applied on startup
## Contributing to **BCS Official**
1) Add pull request to `https://git.bahmcloud.de/bahmcloud/ha_store` (with your integration added to) `store.yaml`)
2) Add entry:
```yaml ```yaml
- name: Your Integration Name name: Example Project
url: https://your-git-hoster.com/your-org/your-repo description: Short description
category: Category (actually only "Integrations" are supported) category: Integrations
author: Example Author
maintainer: Example Maintainer
``` ```
3) (Recommended) Add `bcs.yaml` to your repo:
Category currently matters operationally:
- `Integrations` -> install from `custom_components/...`
- `Blueprint` / `Blueprints` -> install from `blueprints/...`
## Supported Install Categories
### Integrations
- Expected source layout: `custom_components/<domain>/manifest.json`
- Install target: `/config/custom_components/<domain>`
- Supports backup, restore, update entities, and restart-required flow
### Blueprints
- Expected source layout: `blueprints/...`
- Install target: `/config/blueprints/...`
- 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
## HTTP API
Base path: `/api/bcs`
- `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/release_notes?repo_id=...&ref=...`
- `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=...`
## Current UI Features
- Search, source filter, category filter, state filter, sorting
- HACS integration source toggle
- Pinned repositories
- README rendering
- Release notes rendering
- Version selection
- Backup restore UI for integrations and blueprints
## Contributing to the Official Store Index
Add a repository to the BCS store index in `ha_store`.
Example index entry:
```yaml ```yaml
name: Your Integration Name - name: Example Project
description: One-liner for the store (optional, store information are also catched from git repository) url: https://your-git-hoster.example/org/repo
category: Integrations (actually only supported) category: Blueprint
author: Your Name
maintainer: Your Handle
``` ```
4) Open PR; validation checks: reachable, has `custom_components/<domain>/manifest.json`, sensible metadata
5) Merge → appears in **BCS Official** after refresh
## Coding Guidelines Recommended repository metadata:
- Async I/O, no blocking event loop ```yaml
- Respect provider rate limits name: Example Project
- Clean logging around refresh/install/update/restore description: One-line description
- Keep UI responsive; throttle updates category: Blueprint
author: Example Author
maintainer: Example Maintainer
```
--- Validation should match the category:
## Planed Features - Integrations: verify `custom_components/<domain>/manifest.json`
- Blueprints: verify `blueprints/...`
- Add Downloads and install for category "Dashboard" ## Design Direction
- Add Downloads and install for category "Template"
- Add Downloads and install for category "Theme"
- Add Downloads and install for category "Blueprint"
-
The long-term architecture should remain category-aware:
- category -> validation strategy
- category -> install target
- category -> backup / restore behavior
- 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.

View File

@@ -1,27 +1,30 @@
# Bahmcloud Store Full User Guide # Bahmcloud Store - Full User Guide
This guide explains **all features** of Bahmcloud Store (BCS) for Home Assistant. This guide explains the current feature set of Bahmcloud Store (BCS) for Home Assistant.
It is written for users and admins who want a complete, practical reference.
> BCS lets you install & manage custom integrations from **GitHub/GitLab/Gitea** and your own sources, with backups, restore, and version pinning. BCS is a git-based store UI that can currently manage integrations and blueprints from multiple repository providers.
--- ---
## Contents ## Contents
- Concepts - Concepts
- Sources (BCS / HACS / Custom) - Sources
- Supported Content Types
- UI Overview - UI Overview
- Finding Integrations - Finding Repositories
- Installing - Installing
- Selecting Versions / Downgrading - Selecting Versions
- Updating - Updating
- Uninstalling - Uninstalling
- Backups & Restore - Pinned Repositories
- Release Notes
- Backups and Restore
- Custom Repositories - Custom Repositories
- HACS Repositories - HACS Repositories
- Update Entities in Home Assistant - Update Entities
- Background Caching & Performance - Performance and Caching
- Restart Required - Restart Behavior
- Troubleshooting - Troubleshooting
- FAQ - FAQ
@@ -29,189 +32,293 @@ It is written for users and admins who want a complete, practical reference.
## Concepts ## Concepts
- **Sources**: ### Sources
- **BCS Official** → entries from index (`store.yaml`) made and added from Bahmcloud
- **HACS** → official HACS integrations list (toggleable) - **BCS Official**: repositories from the official Bahmcloud index
- **Custom** → manual entries you add locally - **HACS**: optional HACS integration source
- **Install location**: `/config/custom_components/<domain>` - **Custom**: repositories you add manually by URL
- **Backup**: BCS keeps preupdate copies in `/config/.bcs_backups/<domain>/<timestamp>/`
### Installed Ref
BCS stores the installed ref separately from any version declared inside the repository itself.
That makes update checks more reliable when a repository uses tags or releases that do not exactly match its internal manifest version.
--- ---
## Sources (BCS / HACS / Custom) ## Supported Content Types
Each repository card shows a **source badge**: ### Integrations
- **BCS Official** from Bahmcloud index
- **HACS** from HACS official list (enable with the toggle)
- **Custom** added by you
You can **filter by source** with the **Source** dropdown (All / BCS Official / HACS / Custom). - Source layout: `custom_components/<domain>/...`
- Install target: `/config/custom_components/<domain>`
- Supports install, update, downgrade, uninstall, backups, restore, and update entities
- Restart required after install, update, uninstall, or restore
### Blueprints
- Source layout: `blueprints/...`
- Install target: `/config/blueprints/...`
- Supports install, update, uninstall, backup, and restore through the store
- Intended for blueprint repositories without integration-specific folder structures
--- ---
## UI Overview ## UI Overview
Top bar: Store view:
- **Search** (name/description)
- **Source** filter (All/BCS Official/HACS/Custom)
- **Category** filter (actually only Integrations are supported)
- **State-Filter** filter (All/Installed/Not installed/Update available/Custom repos)
- **Sort** (name (A-Z)/Updates first/Installed first)
- **HACS official** toggle (on/off)
Repository card: - Search
- Name, description, badges (source, installed/update), category - Source filter
- Buttons: **Install / Update / Uninstall** - Category filter
- **Readme** expandable - State filter
- **Open** to see details (available versions, metadata) - Sorting
- HACS source toggle
- Pinned repository filtering and sorting
Repository detail view:
- README rendering
- Release notes for provider releases
- Version selector
- Install / Update / Uninstall actions
- Pin / Unpin action
- Backup restore action for integrations
- Category and install target information
--- ---
## Finding Integrations ## Finding Repositories
1. Use **Search** to filter by keywords. 1. Use **Search** to filter by name, description, or URL.
2. Combine with **Category** and **Source**. 2. Use **Source** to switch between BCS Official, HACS, and Custom repositories.
3. Sort to surface desired results. 3. Use **Category** to focus on a specific content type.
4. Use **Pinned** filtering or sorting to surface repositories you watch often.
Descriptions and latest versions are filled progressively by a background process; opening a repo loads details on demand. Descriptions, metadata, and latest versions may appear progressively because BCS enriches repository information in the background.
--- ---
## Installing ## Installing
1. Open a repository. ### Integration Install
2. Optionally select **Install version** (default: **Latest**).
3. Click **Install** and wait for confirmation.
4. Follow the **Restart required** prompt. (or use restart toggle in HomeAssistant Settings)
**What happens internally** 1. Open a repository.
- BCS downloads the repository ZIP for the selected version (release/tag/branch). 2. Optionally select a version.
- It extracts all integrations found under `custom_components/<domain>` and deploys them. 3. Click **Install**.
- It saves the **installed version (ref)** to track updates reliably, even if the repos own `manifest.json` is wrong/outdated. 4. Restart Home Assistant when prompted.
Internally, BCS:
- downloads the selected repository archive
- extracts `custom_components/...`
- installs every valid integration found there
- stores the installed ref
- creates backups before overwriting existing integration folders
### Blueprint Install
1. Open a blueprint repository.
2. Optionally select a version.
3. Click **Install**.
Internally, BCS:
- downloads the selected repository archive
- extracts `blueprints/...`
- copies blueprint files into `/config/blueprints/...`
Blueprint installs normally do not require a Home Assistant restart.
--- ---
## Selecting Versions / Downgrading ## Selecting Versions
- Use the **Install version** dropdown in the detail view. - Use the version selector in the detail view.
- Choose **Latest** or a previous **release/tag**. - `Latest` uses the newest provider release or tag BCS can determine.
- Installing a chosen ref **pins** the integration to that ref (no surprise updates). - You can install an older release or tag when available.
- You can upgrade again later by selecting **Latest** and clicking **Update**. - Release notes are shown when the selected version is a provider release with a body.
--- ---
## Updating ## Updating
- The **Update** button appears when `latest_version` differs from your **installed version (ref)**. ### Integrations
- Updates are also available via **Home Assistant → Settings → Updates** (native Update entity).
- Clicking **Update** runs the same safe pipeline as **Install** (with backup).
**Tip:** Opening a repository detail view forces an immediate check for the latest version for that repo. - The **Update** button appears when the latest ref differs from the installed ref.
- Updates are also available through **Settings -> Updates** via native Home Assistant update entities.
- Integration updates create backups before overwriting files.
### Blueprints
- 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.
--- ---
## Uninstalling ## Uninstalling
- Click **Uninstall** on the repository. ### Integrations
- BCS removes the integration folders under `custom_components/<domain>`.
- The installed state is cleared in the Store. - Removes the installed integration directories from `/config/custom_components`
- Restart Home Assistant if prompted. - Clears the installed state in BCS
- Usually requires a restart
### Blueprints
- Removes the installed blueprint files recorded by BCS
- Cleans up empty directories below `/config/blueprints` when possible
- Does not require a restart under normal conditions
--- ---
## Backups & Restore ## Pinned Repositories
Before an update/install over existing files, BCS creates a backup: You can pin repositories to keep important items easy to find.
``` Current behavior:
/config/.bcs_backups/<domain>/<timestamp>/
```
**Restore**: - Pin or unpin from the detail view
1. Open the repository in store. - Filter the store by pinned repositories
2. Select **Restore…**. - Sort the store with pinned repositories first
3. Pick one of the **last backups** (up to retention limit). (Version details are showed in ()) - Pinned repositories show a dedicated badge in the store overview
4. Confirm BCS restores files and reconciles installed version to the restored ref. - Pinned state is persisted in settings
5. Restart Home Assistant if prompted.
If the old backup lacks metadata, BCS besteffort derives the installed version from the backups `manifest.json`, or marks the ref as `restored:<timestamp>` so updates remain possible. ---
## Release Notes
When a provider release has a release body, BCS can show release notes in the detail view for the selected version.
Notes:
- Release notes are available for real releases, not every tag or branch
- If no provider release body exists, BCS shows a clear not-available state
---
## Backups and Restore
Integration installs and updates create backups before overwriting existing files.
Blueprint updates also create content backups before overwriting deployed blueprint files.
Backup path:
`/config/.bcs_backups/<domain>/<timestamp>/`
Restore flow:
1. Open the repository detail
2. Click **Restore**
3. Select a backup
4. Confirm restore
5. Restart Home Assistant if prompted
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.
--- ---
## Custom Repositories ## Custom Repositories
You can add any public repository (GitHub/GitLab/Gitea/Any other Git Hoster). BCS will attempt to detect: You can add any public repository URL that matches a supported provider style and contains supported content.
- provider & default branch
- latest version (release/tag/atom)
- repo metadata (prefer `bcs.yaml`, fallback `hacs.json/hacs.yaml`)
- readme (common filenames)
**Add an custom repository**(typical flows): BCS attempts to detect:
- From the Store UI
1. If you start up Bahmcloud Store, you are on tab "Store"
2. Go to tab "Manage"
3. Add Repository URL (Example: ``https://git.bahmcloud.de/bahmcloud/bahmcloud_store``) and (optional) Name you want to show up (will be grabbed from GIT Repository)
4. Repository show up in Custom Repositories-list and in "Store" Tab, if available
**Remove an custom repository**(typical flows): - provider
- From the Store UI - default branch
1. If you start up Bahmcloud Store, you are on tab "Store" - latest version
2. Go to tab "Manage" - metadata from `bcs.yaml`, `hacs.yaml`, or `hacs.json`
3. Your repository you want to remove (if its custom) show up in the list - README content
4. Press "Remove" Button to delete from Store (Attention: If you Remove installed Repository, you won´t be able to Uninstall from Store, you have to remove by your own (Delete Folder from ```config/custom_components/*```))
Custom repos get the **Custom** badge and can be filtered via **Source**. Custom repositories get a **Custom** badge and can be filtered separately.
--- ---
## HACS Repositories ## HACS Repositories
Enable the **HACS official** toggle to include official HACS integrations. Enable the HACS toggle to include official HACS integration repositories.
- BCS downloads the HACS integration list and maps **humanreadable names/descriptions** from HACS metadata. Current scope:
- HACS entries are **not** part of your BCS-Official Repositories (avoid duplicate entries).
With many HACS repos, metadata loads in the background; names/descriptions appear progressively and are cached. - HACS source support is currently focused on integrations
- HACS metadata is used to improve naming and descriptions
--- ---
## Update Entities in Home Assistant ## Update Entities
BCS exposes update entities for installed repos: BCS exposes Home Assistant update entities for installed integrations.
- Found under **Settings → Updates**
- Clicking **Install** triggers BCS update pipeline - Found under **Settings -> Updates**
- Shows **installed** and **latest** versions (BCS ref logic) - Shows installed ref and latest ref
- Lets Home Assistant trigger the BCS update pipeline
Blueprint repositories do not currently use the integration-style update entity flow.
--- ---
## Background Caching & Performance ## Performance and Caching
- **Fast initial list**: index + local cache only BCS is designed to keep startup responsive.
- **Background enrichment**: provider info, latest version, metadata, description, readme (best effort)
- **Ondemand**: opening a repo triggers immediate enrichment; data is **persisted** to cache - Heavy work runs after Home Assistant startup
- **Persistent cache**: survives HA restarts; speeds up subsequent runs - Repo data is enriched in the background
- **Refresh**: immediately rechecks installed repos and key metadata - Repository details are cached
- Version lists are cached
- HACS metadata is cached
Opening a repository detail view can force immediate enrichment for that repository.
--- ---
## Restart Required ## Restart Behavior
After install, update, or restore, BCS raises a **Restart required** item in Home Assistant (Repairs). You can restart directly from there. Restart usually matters only for integration changes.
- Integration install/update/uninstall/restore: restart expected
- Blueprint install/update/uninstall/restore: restart usually not needed
BCS uses a Home Assistant repair flow to surface restart requirements for integration changes.
--- ---
## Troubleshooting ## Troubleshooting
- **New release but no update**: Open the repo detail once; ensure its a **release/tag** (commits alone dont change the ref). - **Latest version missing**: open the repository detail once to force a recheck
- **Descriptions/Latest missing**: Wait for background enrichment or open the repo detail (forces enrichment). Cached afterwards. - **Release notes missing**: the selected version may be a tag or branch without a provider release body
(You also can Install new version by selecting in Version tab) - **Blueprint install fails**: verify the repository really contains a `blueprints/` folder
- **Slow startup**: BCS schedules heavy work after HA started. Keep indexes reasonable. - **Integration install fails**: verify the repository contains `custom_components/<domain>/manifest.json`
- **Repo appears but cannot install**: metadata can describe the category, but the repository layout still has to match that category
--- ---
## FAQ ## FAQ
- **Backups path?** `/config/.bcs_backups/<domain>/<timestamp>/` ### Where are integrations installed?
- **Install path?** `/config/custom_components/<domain>`
- **Downgrade?** Yes, pick an older version and install. `/config/custom_components/<domain>`
- **Restart needed?** Yes, after install/update/restore.
### Where are blueprints installed?
`/config/blueprints/...`
### Are pinned repositories persistent?
Yes.
### Do blueprints require a restart?
Usually no.
### Do integrations require a restart?
Yes, normally after install, update, uninstall, or restore.

View File

@@ -3,41 +3,63 @@ from __future__ import annotations
import logging import logging
from datetime import timedelta from datetime import timedelta
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.components.panel_custom import async_register_panel from homeassistant.components.panel_custom import async_register_panel
from homeassistant.helpers.event import async_track_time_interval, async_call_later
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.event import async_call_later, async_track_time_interval
from .core import BCSCore, BCSConfig, BCSError from .const import CONF_GITHUB_TOKEN, DEFAULT_STORE_URL, DOMAIN
from .core import BCSError, BCSConfig, BCSCore
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DOMAIN = "bahmcloud_store" PLATFORMS: list[str] = ["update"]
DEFAULT_STORE_URL = "https://git.bahmcloud.de/bahmcloud/ha_store/raw/branch/main/store.yaml"
CONF_STORE_URL = "store_url"
async def async_setup(hass: HomeAssistant, config: dict) -> bool: async def async_setup(hass: HomeAssistant, config: dict) -> bool:
cfg = config.get(DOMAIN, {}) or {} """Set up Bahmcloud Store.
store_url = cfg.get(CONF_STORE_URL, DEFAULT_STORE_URL)
core = BCSCore(hass, BCSConfig(store_url=store_url)) We intentionally do NOT support YAML configuration.
hass.data[DOMAIN] = core This method is kept so we can log a helpful message if someone tries.
"""
if DOMAIN in (config or {}):
_LOGGER.warning(
"BCS YAML configuration is no longer supported. "
"Please remove 'bahmcloud_store:' from configuration.yaml and set up the integration via the UI."
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Bahmcloud Store from a config entry (UI setup)."""
# Only one instance.
hass.data.setdefault(DOMAIN, {})
github_token = (entry.options.get(CONF_GITHUB_TOKEN) or "").strip() or None
core = BCSCore(
hass,
BCSConfig(
store_url=DEFAULT_STORE_URL,
github_token=github_token,
),
)
hass.data[DOMAIN][entry.entry_id] = core
# Keep a convenient shortcut for platforms that previously used hass.data[DOMAIN] directly.
hass.data[DOMAIN]["_core"] = core
await core.async_initialize() await core.async_initialize()
# Provide native Update entities in Settings -> System -> Updates. # HTTP views + panel (registered once per entry; we only allow one entry).
# This integration is YAML-based (async_setup), therefore we load the platform manually.
await async_load_platform(hass, "update", DOMAIN, {}, config)
from .views import ( from .views import (
StaticAssetsView, StaticAssetsView,
BCSApiView, BCSApiView,
BCSSettingsView, BCSSettingsView,
BCSReadmeView, BCSReadmeView,
BCSVersionsView, BCSVersionsView,
BCSReleaseNotesView,
BCSRepoDetailView, BCSRepoDetailView,
BCSCustomRepoView, BCSCustomRepoView,
BCSInstallView, BCSInstallView,
@@ -53,6 +75,7 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
hass.http.register_view(BCSSettingsView(core)) hass.http.register_view(BCSSettingsView(core))
hass.http.register_view(BCSReadmeView(core)) hass.http.register_view(BCSReadmeView(core))
hass.http.register_view(BCSVersionsView(core)) hass.http.register_view(BCSVersionsView(core))
hass.http.register_view(BCSReleaseNotesView(core))
hass.http.register_view(BCSRepoDetailView(core)) hass.http.register_view(BCSRepoDetailView(core))
hass.http.register_view(BCSCustomRepoView(core)) hass.http.register_view(BCSCustomRepoView(core))
hass.http.register_view(BCSInstallView(core)) hass.http.register_view(BCSInstallView(core))
@@ -67,7 +90,7 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
frontend_url_path="bahmcloud-store", frontend_url_path="bahmcloud-store",
webcomponent_name="bahmcloud-store-panel", webcomponent_name="bahmcloud-store-panel",
# IMPORTANT: bump v to avoid caching old JS # IMPORTANT: bump v to avoid caching old JS
module_url="/api/bahmcloud_store_static/panel.js?v=108", module_url="/api/bahmcloud_store_static/panel.js?v=112",
sidebar_title="Bahmcloud Store", sidebar_title="Bahmcloud Store",
sidebar_icon="mdi:store", sidebar_icon="mdi:store",
require_admin=True, require_admin=True,
@@ -80,7 +103,7 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
except BCSError as e: except BCSError as e:
_LOGGER.error("Initial refresh failed: %s", e) _LOGGER.error("Initial refresh failed: %s", e)
# Do not block Home Assistant startup. Schedule the initial refresh after HA started. # Do not block startup; refresh after HA is up.
def _on_ha_started(_event) -> None: def _on_ha_started(_event) -> None:
async_call_later(hass, 30, _do_startup_refresh) async_call_later(hass, 30, _do_startup_refresh)
@@ -97,4 +120,16 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
interval_seconds = int(getattr(core, "refresh_seconds", 300) or 300) interval_seconds = int(getattr(core, "refresh_seconds", 300) or 300)
async_track_time_interval(hass, periodic, timedelta(seconds=interval_seconds)) async_track_time_interval(hass, periodic, timedelta(seconds=interval_seconds))
return True await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
try:
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
except Exception:
pass
return unload_ok

View File

@@ -0,0 +1,71 @@
from __future__ import annotations
import logging
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from .const import CONF_GITHUB_TOKEN, DOMAIN
_LOGGER = logging.getLogger(__name__)
def _schema(default_token: str | None = None) -> vol.Schema:
default_token = (default_token or "").strip()
return vol.Schema({vol.Optional(CONF_GITHUB_TOKEN, default=default_token): str})
class BahmcloudStoreConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Config flow for Bahmcloud Store.
The store index URL is fixed and not user-configurable.
The only optional setting is a GitHub token to increase API rate limits.
"""
VERSION = 1
async def async_step_user(self, user_input: dict | None = None):
# Allow only one instance.
await self.async_set_unique_id(DOMAIN)
self._abort_if_unique_id_configured()
if user_input is None:
return self.async_show_form(step_id="user", data_schema=_schema(None))
token = str(user_input.get(CONF_GITHUB_TOKEN, "")).strip() or None
return self.async_create_entry(
title="Bahmcloud Store",
data={},
options={CONF_GITHUB_TOKEN: token} if token else {},
)
@staticmethod
@callback
def async_get_options_flow(config_entry: config_entries.ConfigEntry):
return BahmcloudStoreOptionsFlowHandler(config_entry)
class BahmcloudStoreOptionsFlowHandler(config_entries.OptionsFlow):
"""Options flow to manage optional GitHub token."""
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
self._config_entry = config_entry
async def async_step_init(self, user_input: dict | None = None):
if user_input is None:
current = self._config_entry.options.get(CONF_GITHUB_TOKEN) or ""
return self.async_show_form(step_id="init", data_schema=_schema(str(current)))
token = str(user_input.get(CONF_GITHUB_TOKEN, "")).strip() or None
options = dict(self._config_entry.options)
# Allow clearing the token.
if token:
options[CONF_GITHUB_TOKEN] = token
else:
options.pop(CONF_GITHUB_TOKEN, None)
return self.async_create_entry(title="", data=options)

View File

@@ -0,0 +1,11 @@
"""Constants for Bahmcloud Store."""
from __future__ import annotations
DOMAIN = "bahmcloud_store"
# Fixed store index URL (not user-configurable).
DEFAULT_STORE_URL = "https://git.bahmcloud.de/bahmcloud/ha_store/raw/branch/main/store.yaml"
# Config entry option keys
CONF_GITHUB_TOKEN = "github_token"

View File

@@ -20,7 +20,14 @@ from homeassistant.helpers import issue_registry as ir
from homeassistant.util import yaml as ha_yaml from homeassistant.util import yaml as ha_yaml
from .storage import BCSStorage, CustomRepo from .storage import BCSStorage, CustomRepo
from .providers import fetch_repo_info, detect_provider, RepoInfo, fetch_readme_markdown, fetch_repo_versions from .providers import (
fetch_repo_info,
detect_provider,
RepoInfo,
fetch_readme_markdown,
fetch_repo_versions,
fetch_release_notes_markdown,
)
from .metadata import fetch_repo_metadata, RepoMetadata from .metadata import fetch_repo_metadata, RepoMetadata
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -58,6 +65,7 @@ class BCSInstallError(BCSError):
@dataclass @dataclass
class BCSConfig: class BCSConfig:
store_url: str store_url: str
github_token: str | None = None
@dataclass @dataclass
@@ -110,7 +118,7 @@ class BCSCore:
self._installed_cache: dict[str, Any] = {} self._installed_cache: dict[str, Any] = {}
# Persistent settings (UI toggles etc.) # Persistent settings (UI toggles etc.)
self.settings: dict[str, Any] = {"hacs_enabled": False} self.settings: dict[str, Any] = {"hacs_enabled": False, "favorite_repo_ids": []}
# Cached HACS metadata (display names/descriptions). Loaded from storage. # Cached HACS metadata (display names/descriptions). Loaded from storage.
self._hacs_meta_fetched_at: int = 0 self._hacs_meta_fetched_at: int = 0
@@ -180,6 +188,108 @@ class BCSCore:
return await self.hass.async_add_executor_job(_read) return await self.hass.async_add_executor_job(_read)
async def _read_manifest_info_async(self) -> dict[str, str]:
"""Read manifest.json fields that help identify this integration."""
def _read() -> dict[str, str]:
try:
manifest_path = Path(__file__).resolve().parent / "manifest.json"
data = json.loads(manifest_path.read_text(encoding="utf-8"))
out: dict[str, str] = {}
for k in ("version", "documentation", "name", "domain"):
v = data.get(k)
if v:
out[str(k)] = str(v)
return out
except Exception:
return {}
return await self.hass.async_add_executor_job(_read)
def _normalize_repo_base(self, url: str) -> str:
"""Normalize repository URLs to a stable base for matching.
Example:
https://git.example.tld/org/repo/raw/branch/main/store.yaml
becomes:
https://git.example.tld/org/repo
"""
try:
p = urlsplit(str(url or "").strip())
parts = [x for x in (p.path or "").split("/") if x]
base_path = "/" + "/".join(parts[:2]) if len(parts) >= 2 else (p.path or "")
return urlunsplit((p.scheme, p.netloc, base_path.rstrip("/"), "", "")).lower()
except Exception:
return str(url or "").strip().lower()
async def _ensure_self_marked_installed(self, repos: dict[str, RepoItem]) -> None:
"""Ensure BCS is treated as installed when deployed via external installer.
When users install BCS via an installer that places files into
/config/custom_components, our internal storage has no installed entry.
This breaks update detection for the BCS repo entry in the Store.
"""
try:
# Already tracked as installed?
items = await self.storage.list_installed_repos()
for it in items:
if DOMAIN in [str(d) for d in (it.domains or [])]:
return
# Files must exist on disk.
cc_root = Path(self.hass.config.path("custom_components"))
manifest_path = cc_root / DOMAIN / "manifest.json"
if not manifest_path.exists():
return
info = await self._read_manifest_info_async()
doc = (info.get("documentation") or "").strip()
name = (info.get("name") or "").strip()
ver = (info.get("version") or self.version or "unknown").strip()
doc_base = self._normalize_repo_base(doc) if doc else ""
# Identify the matching repo entry in our current repo list.
chosen: RepoItem | None = None
if doc_base:
for r in repos.values():
if self._normalize_repo_base(r.url) == doc_base:
chosen = r
break
if not chosen and name:
for r in repos.values():
if (r.name or "").strip().lower() == name.lower():
chosen = r
break
if not chosen:
for r in repos.values():
if "bahmcloud_store" in (r.url or "").lower():
chosen = r
break
if not chosen:
_LOGGER.debug("BCS self-install reconcile: could not match repo entry")
return
await self.storage.set_installed_repo(
repo_id=chosen.id,
url=chosen.url,
domains=[DOMAIN],
installed_version=ver if ver != "unknown" else None,
installed_manifest_version=ver if ver != "unknown" else None,
ref=ver if ver != "unknown" else None,
)
_LOGGER.info(
"BCS self-install reconcile: marked as installed (repo_id=%s version=%s)",
chosen.id,
ver,
)
except Exception:
_LOGGER.debug("BCS self-install reconcile failed", exc_info=True)
def add_listener(self, cb) -> None: def add_listener(self, cb) -> None:
self._listeners.append(cb) self._listeners.append(cb)
@@ -238,24 +348,52 @@ class BCSCore:
def get_settings_public(self) -> dict[str, Any]: def get_settings_public(self) -> dict[str, Any]:
"""Return UI-relevant settings (no I/O).""" """Return UI-relevant settings (no I/O)."""
favorite_repo_ids = self.settings.get("favorite_repo_ids") or []
if not isinstance(favorite_repo_ids, list):
favorite_repo_ids = []
return { return {
"hacs_enabled": bool(self.settings.get("hacs_enabled", False)), "hacs_enabled": bool(self.settings.get("hacs_enabled", False)),
"favorite_repo_ids": [str(x) for x in favorite_repo_ids if str(x).strip()],
} }
async def set_settings(self, updates: dict[str, Any]) -> dict[str, Any]: async def set_settings(self, updates: dict[str, Any]) -> dict[str, Any]:
"""Persist settings and apply them.""" """Persist settings and apply them."""
safe_updates: dict[str, Any] = {} safe_updates: dict[str, Any] = {}
reload_required = False
if "hacs_enabled" in (updates or {}): if "hacs_enabled" in (updates or {}):
safe_updates["hacs_enabled"] = bool(updates.get("hacs_enabled")) safe_updates["hacs_enabled"] = bool(updates.get("hacs_enabled"))
reload_required = True
if "favorite_repo_ids" in (updates or {}):
raw = updates.get("favorite_repo_ids") or []
if not isinstance(raw, list):
raw = []
favorite_ids: list[str] = []
seen: set[str] = set()
for item in raw:
rid = str(item or "").strip()
if not rid or rid in seen:
continue
seen.add(rid)
favorite_ids.append(rid)
safe_updates["favorite_repo_ids"] = favorite_ids
merged = await self.storage.set_settings(safe_updates) merged = await self.storage.set_settings(safe_updates)
if isinstance(merged, dict): if isinstance(merged, dict):
self.settings.update(merged) self.settings.update(merged)
# Reload repo list after changing settings. if reload_required:
await self.full_refresh(source="settings") await self.full_refresh(source="settings")
else:
self.signal_updated()
return self.get_settings_public() return self.get_settings_public()
def is_favorite_repo(self, repo_id: str) -> bool:
favorite_repo_ids = self.settings.get("favorite_repo_ids") or []
if not isinstance(favorite_repo_ids, list):
return False
target = str(repo_id or "").strip()
return bool(target) and target in [str(x).strip() for x in favorite_repo_ids]
async def refresh(self) -> None: async def refresh(self) -> None:
index_repos, refresh_seconds = await self._load_index_repos() index_repos, refresh_seconds = await self._load_index_repos()
self.refresh_seconds = refresh_seconds self.refresh_seconds = refresh_seconds
@@ -323,6 +461,12 @@ class BCSCore:
# Apply persisted per-repo enrichment cache (instant UI after restart). # Apply persisted per-repo enrichment cache (instant UI after restart).
self._apply_repo_cache(merged) self._apply_repo_cache(merged)
# If BCS itself was installed via an external installer (i.e. files exist on disk
# but our storage has no installed entry yet), we still want update checks to work.
# Reconcile this once we have the current repo list.
await self._ensure_self_marked_installed(merged)
await self._refresh_installed_cache()
await self._enrich_installed_only(merged) await self._enrich_installed_only(merged)
self.repos = merged self.repos = merged
@@ -696,7 +840,7 @@ class BCSCore:
async def process_one(r: RepoItem) -> None: async def process_one(r: RepoItem) -> None:
async with sem: async with sem:
info: RepoInfo = await fetch_repo_info(self.hass, r.url) info: RepoInfo = await fetch_repo_info(self.hass, r.url, github_token=self.config.github_token)
r.provider = info.provider or r.provider r.provider = info.provider or r.provider
r.owner = info.owner or r.owner r.owner = info.owner or r.owner
@@ -755,7 +899,7 @@ class BCSCore:
async def _enrich_one_repo(self, r: RepoItem) -> None: async def _enrich_one_repo(self, r: RepoItem) -> None:
"""Fetch provider info + metadata for a single repo item.""" """Fetch provider info + metadata for a single repo item."""
info: RepoInfo = await fetch_repo_info(self.hass, r.url) info: RepoInfo = await fetch_repo_info(self.hass, r.url, github_token=self.config.github_token)
r.provider = info.provider or r.provider r.provider = info.provider or r.provider
r.owner = info.owner or r.owner r.owner = info.owner or r.owner
@@ -807,13 +951,21 @@ class BCSCore:
_LOGGER.debug("BCS ensure_repo_details failed for %s", repo_id, exc_info=True) _LOGGER.debug("BCS ensure_repo_details failed for %s", repo_id, exc_info=True)
return r return r
async def list_repo_versions(self, repo_id: str) -> list[dict[str, Any]]: async def list_repo_versions(
self,
repo_id: str,
*,
limit: int = 20,
force_refresh: bool = False,
) -> list[dict[str, Any]]:
repo = self.get_repo(repo_id) repo = self.get_repo(repo_id)
if not repo: if not repo:
return [] return []
# Prefer cached version lists to avoid hammering provider APIs (notably GitHub unauthenticated # Prefer cached version lists to avoid hammering provider APIs (notably GitHub unauthenticated
# rate limits). We refresh on-demand when the user opens the selector. # rate limits). However, if the cached list is clearly a degraded fallback (e.g. only
# "Latest" + "Branch"), we treat it as stale and retry immediately when the user requests
# versions again.
cached = None cached = None
cached_ts = 0 cached_ts = 0
async with self._repo_cache_lock: async with self._repo_cache_lock:
@@ -822,11 +974,27 @@ class BCSCore:
cached_ts = int(cached.get("versions_ts", 0) or 0) cached_ts = int(cached.get("versions_ts", 0) or 0)
now = int(time.time()) now = int(time.time())
if isinstance(cached, dict) and cached.get("versions") and (now - cached_ts) < VERSIONS_CACHE_TTL_SECONDS: cached_versions = list(cached.get("versions") or []) if isinstance(cached, dict) else []
return list(cached.get("versions") or []) cache_fresh = (now - cached_ts) < VERSIONS_CACHE_TTL_SECONDS
# Cache hit if it's fresh and not degraded, unless the caller explicitly wants a refresh.
if (
not force_refresh
and cached_versions
and cache_fresh
and len(cached_versions) > 2
):
return cached_versions
try: try:
versions = await fetch_repo_versions(self.hass, repo.url) versions = await fetch_repo_versions(
self.hass,
repo.url,
provider=repo.provider,
default_branch=repo.default_branch,
limit=limit,
github_token=self.config.github_token,
)
except Exception: except Exception:
versions = [] versions = []
@@ -1004,13 +1172,18 @@ class BCSCore:
"latest_version": r.latest_version, "latest_version": r.latest_version,
"latest_version_source": r.latest_version_source, "latest_version_source": r.latest_version_source,
"category": r.meta_category, "category": r.meta_category,
"category_key": self._repo_install_type(r),
"install_target": self._repo_install_target(r),
"meta_author": r.meta_author, "meta_author": r.meta_author,
"meta_maintainer": r.meta_maintainer, "meta_maintainer": r.meta_maintainer,
"meta_source": r.meta_source, "meta_source": r.meta_source,
"installed": installed, "installed": installed,
"install_type": str(inst.get("install_type") if isinstance(inst, dict) else self._repo_install_type(r)),
"installed_version": installed_version, "installed_version": installed_version,
"installed_manifest_version": installed_manifest_version, "installed_manifest_version": installed_manifest_version,
"installed_domains": installed_domains, "installed_domains": installed_domains,
"installed_paths": list(inst.get("installed_paths") or []) if isinstance(inst, dict) else [],
"favorite": self.is_favorite_repo(r.id),
} }
) )
return out return out
@@ -1027,21 +1200,21 @@ class BCSCore:
default_branch=repo.default_branch, default_branch=repo.default_branch,
) )
async def list_repo_versions(self, repo_id: str, *, limit: int = 20) -> list[dict[str, str]]: async def fetch_release_notes_markdown(self, repo_id: str, ref: str | None = None) -> str | None:
"""List installable versions/refs for a repo.
This is used by the UI to allow selecting an older tag/release.
"""
repo = self.get_repo(repo_id) repo = self.get_repo(repo_id)
if not repo: if not repo:
raise BCSInstallError(f"repo_id not found: {repo_id}") return None
return await fetch_repo_versions( target_ref = (ref or "").strip() or (repo.latest_version or "").strip()
if not target_ref:
return None
return await fetch_release_notes_markdown(
self.hass, self.hass,
repo.url, repo.url,
ref=target_ref,
provider=repo.provider, provider=repo.provider,
default_branch=repo.default_branch, github_token=self.config.github_token,
limit=limit,
) )
def _pick_ref_for_install(self, repo: RepoItem) -> str: def _pick_ref_for_install(self, repo: RepoItem) -> str:
@@ -1051,6 +1224,32 @@ class BCSCore:
return str(repo.default_branch).strip() return str(repo.default_branch).strip()
return "main" return "main"
@staticmethod
def _normalize_category_key(category: str | None) -> str:
raw = str(category or "").strip().lower()
if raw in ("integration", "integrations"):
return "integration"
if raw in ("blueprint", "blueprints"):
return "blueprint"
if raw in ("template", "templates"):
return "template"
if raw in ("lovelace", "dashboard", "dashboards", "lovelace design", "lovelace designs"):
return "lovelace"
return "integration"
def _repo_install_type(self, repo: RepoItem | None) -> str:
return self._normalize_category_key(getattr(repo, "meta_category", None))
def _repo_install_target(self, repo: RepoItem | None) -> str:
install_type = self._repo_install_type(repo)
if install_type == "blueprint":
return "/config/blueprints"
if install_type == "template":
return "/config"
if install_type == "lovelace":
return "/config"
return "/config/custom_components"
def _build_zip_url(self, repo_url: str, ref: str) -> str: def _build_zip_url(self, repo_url: str, ref: str) -> str:
ref = (ref or "").strip() ref = (ref or "").strip()
if not ref: if not ref:
@@ -1117,6 +1316,18 @@ class BCSCore:
return candidate return candidate
return None return None
@staticmethod
def _find_blueprints_root(extract_root: Path) -> Path | None:
direct = extract_root / "blueprints"
if direct.exists() and direct.is_dir():
return direct
for child in extract_root.iterdir():
candidate = child / "blueprints"
if candidate.exists() and candidate.is_dir():
return candidate
return None
async def _ensure_backup_root(self) -> None: async def _ensure_backup_root(self) -> None:
"""Create backup root directory if needed.""" """Create backup root directory if needed."""
def _mkdir() -> None: def _mkdir() -> None:
@@ -1124,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 {}
@@ -1180,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"))
@@ -1217,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.
@@ -1227,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 []
@@ -1278,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")
@@ -1374,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)."""
@@ -1471,15 +1862,25 @@ class BCSCore:
for it in items: for it in items:
domains = [str(d) for d in (it.domains or []) if str(d).strip()] domains = [str(d) for d in (it.domains or []) if str(d).strip()]
install_type = str(getattr(it, "install_type", "integration") or "integration").strip() or "integration"
installed_paths = [str(p) for p in (getattr(it, "installed_paths", None) or []) if str(p).strip()]
# A repo is considered "present" if at least one of its domains # A repo is considered "present" if at least one of its domains
# exists and contains a manifest.json. # exists and contains a manifest.json.
present = False present = False
for d in domains: if install_type == "integration":
p = cc_root / d for d in domains:
if p.is_dir() and (p / "manifest.json").exists(): p = cc_root / d
present = True if p.is_dir() and (p / "manifest.json").exists():
break present = True
break
else:
cfg_root = Path(self.hass.config.path(""))
for rel in installed_paths:
p = cfg_root / rel
if p.exists():
present = True
break
if not present: if not present:
to_remove.append(it.repo_id) to_remove.append(it.repo_id)
@@ -1488,6 +1889,8 @@ class BCSCore:
cache[it.repo_id] = { cache[it.repo_id] = {
"installed": True, "installed": True,
"domains": domains, "domains": domains,
"install_type": install_type,
"installed_paths": installed_paths,
"installed_version": it.installed_version, "installed_version": it.installed_version,
"installed_manifest_version": it.installed_manifest_version, "installed_manifest_version": it.installed_manifest_version,
"ref": it.ref, "ref": it.ref,
@@ -1523,24 +1926,50 @@ class BCSCore:
if path.exists() and path.is_dir(): if path.exists() and path.is_dir():
shutil.rmtree(path, ignore_errors=True) shutil.rmtree(path, ignore_errors=True)
for domain in inst.domains: def _remove_file(path: Path) -> None:
d = str(domain).strip() if path.exists() and path.is_file():
if not d: path.unlink(missing_ok=True)
continue
target = cc_root / d def _prune_empty_parents(path: Path, stop_at: Path) -> None:
await self.hass.async_add_executor_job(_remove_dir, target) cur = path.parent
removed.append(d) while cur != stop_at and str(cur).startswith(str(stop_at)):
try:
if any(cur.iterdir()):
break
cur.rmdir()
except Exception:
break
cur = cur.parent
install_type = str(getattr(inst, "install_type", "integration") or "integration").strip() or "integration"
if install_type == "integration":
for domain in inst.domains:
d = str(domain).strip()
if not d:
continue
target = cc_root / d
await self.hass.async_add_executor_job(_remove_dir, target)
removed.append(d)
elif install_type == "blueprint":
cfg_root = Path(self.hass.config.path(""))
blueprints_root = Path(self.hass.config.path("blueprints"))
for rel in [str(p).strip() for p in (inst.installed_paths or []) if str(p).strip()]:
target = cfg_root / rel
await self.hass.async_add_executor_job(_remove_file, target)
await self.hass.async_add_executor_job(_prune_empty_parents, target, blueprints_root)
removed.append(rel)
await self.storage.remove_installed_repo(repo_id) await self.storage.remove_installed_repo(repo_id)
await self._refresh_installed_cache() await self._refresh_installed_cache()
# Show restart required in Settings. # Show restart required in Settings.
if removed: if removed and install_type == "integration":
self._mark_restart_required() self._mark_restart_required()
_LOGGER.info("BCS uninstall complete: repo_id=%s removed_domains=%s", repo_id, removed) _LOGGER.info("BCS uninstall complete: repo_id=%s removed_domains=%s", repo_id, removed)
self.signal_updated() self.signal_updated()
return {"ok": True, "repo_id": repo_id, "removed": removed, "restart_required": bool(removed)} return {"ok": True, "repo_id": repo_id, "removed": removed, "restart_required": bool(removed) if install_type == "integration" else False}
async def install_repo(self, repo_id: str, *, version: str | None = None) -> dict[str, Any]: async def install_repo(self, repo_id: str, *, version: str | None = None) -> dict[str, Any]:
repo = self.get_repo(repo_id) repo = self.get_repo(repo_id)
@@ -1555,7 +1984,10 @@ class BCSCore:
_LOGGER.info("BCS install started: repo_id=%s ref=%s zip_url=%s", repo_id, ref, zip_url) _LOGGER.info("BCS install started: repo_id=%s ref=%s zip_url=%s", repo_id, ref, zip_url)
installed_domains: list[str] = [] installed_domains: 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)
inst_before = self.get_installed(repo_id) or {} inst_before = self.get_installed(repo_id) or {}
backup_meta = { backup_meta = {
@@ -1576,57 +2008,97 @@ class BCSCore:
await self._download_zip(zip_url, zip_path) await self._download_zip(zip_url, zip_path)
await self._extract_zip(zip_path, extract_dir) await self._extract_zip(zip_path, extract_dir)
cc_root = self._find_custom_components_root(extract_dir) if install_type == "blueprint":
if not cc_root: blueprints_root = self._find_blueprints_root(extract_dir)
raise BCSInstallError("custom_components folder not found in repository ZIP") if not blueprints_root:
raise BCSInstallError("blueprints folder not found in repository ZIP")
dest_root = Path(self.hass.config.path("custom_components")) target_root = Path(self.hass.config.path("blueprints"))
planned_paths: list[str] = []
for domain_dir in cc_root.iterdir(): for src in blueprints_root.rglob("*"):
if not domain_dir.is_dir(): if not src.is_file():
continue continue
manifest = domain_dir / "manifest.json" rel = src.relative_to(blueprints_root)
if not manifest.exists(): planned_paths.append(str(Path("blueprints") / rel).replace("\\", "/"))
continue
domain = domain_dir.name if not planned_paths:
target = dest_root / domain raise BCSInstallError("No blueprint files found under blueprints/")
# Backup only if we are going to overwrite an existing domain. m = dict(backup_meta)
if target.exists() and target.is_dir(): m["install_type"] = "blueprint"
m = dict(backup_meta) m["installed_paths"] = planned_paths
m["domain"] = domain content_backup = await self._backup_paths(repo_id, planned_paths, meta=m)
bkp = await self._backup_domain(domain, meta=m)
if bkp:
backups[domain] = bkp
else:
created_new.add(domain)
await self._copy_domain_dir(domain_dir, domain) def _copy_blueprints() -> list[str]:
installed_domains.append(domain) copied: list[str] = []
target_root.mkdir(parents=True, exist_ok=True)
for src in blueprints_root.rglob("*"):
if not src.is_file():
continue
rel = src.relative_to(blueprints_root)
dest = target_root / rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
copied.append(str(Path("blueprints") / rel).replace("\\", "/"))
return copied
if not installed_domains: installed_paths = await self.hass.async_add_executor_job(_copy_blueprints)
raise BCSInstallError("No integrations found under custom_components/ (missing manifest.json)") else:
cc_root = self._find_custom_components_root(extract_dir)
if not cc_root:
raise BCSInstallError("custom_components folder not found in repository ZIP")
installed_manifest_version = await self._read_installed_manifest_version(installed_domains[0]) dest_root = Path(self.hass.config.path("custom_components"))
for domain_dir in cc_root.iterdir():
if not domain_dir.is_dir():
continue
manifest = domain_dir / "manifest.json"
if not manifest.exists():
continue
domain = domain_dir.name
target = dest_root / domain
# Backup only if we are going to overwrite an existing domain.
if target.exists() and target.is_dir():
m = dict(backup_meta)
m["domain"] = domain
bkp = await self._backup_domain(domain, meta=m)
if bkp:
backups[domain] = bkp
else:
created_new.add(domain)
await self._copy_domain_dir(domain_dir, domain)
installed_domains.append(domain)
if not installed_domains:
raise BCSInstallError("No integrations found under custom_components/ (missing manifest.json)")
installed_manifest_version = await self._read_installed_manifest_version(installed_domains[0]) if installed_domains else None
installed_version = ref installed_version = ref
await self.storage.set_installed_repo( await self.storage.set_installed_repo(
repo_id=repo_id, repo_id=repo_id,
url=repo.url, url=repo.url,
domains=installed_domains, domains=installed_domains,
install_type=install_type,
installed_paths=installed_paths,
installed_version=installed_version, installed_version=installed_version,
installed_manifest_version=installed_manifest_version, installed_manifest_version=installed_manifest_version,
ref=ref, ref=ref,
) )
await self._refresh_installed_cache() await self._refresh_installed_cache()
self._mark_restart_required() if install_type == "integration":
self._mark_restart_required()
_LOGGER.info( _LOGGER.info(
"BCS install complete: repo_id=%s domains=%s installed_ref=%s manifest_version=%s", "BCS install complete: repo_id=%s domains=%s installed_ref=%s manifest_version=%s",
repo_id, repo_id,
installed_domains, installed_domains if installed_domains else installed_paths,
installed_version, installed_version,
installed_manifest_version, installed_manifest_version,
) )
@@ -1635,9 +2107,11 @@ class BCSCore:
"ok": True, "ok": True,
"repo_id": repo_id, "repo_id": repo_id,
"domains": installed_domains, "domains": installed_domains,
"installed_paths": installed_paths,
"install_type": install_type,
"installed_version": installed_version, "installed_version": installed_version,
"installed_manifest_version": installed_manifest_version, "installed_manifest_version": installed_manifest_version,
"restart_required": True, "restart_required": True if install_type == "integration" else False,
} }
except Exception as e: except Exception as e:
@@ -1653,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:
@@ -1674,4 +2154,4 @@ class BCSCore:
return await self.install_repo(repo_id, version=version) return await self.install_repo(repo_id, version=version)
async def request_restart(self) -> None: async def request_restart(self) -> None:
await self.hass.services.async_call("homeassistant", "restart", {}, blocking=False) await self.hass.services.async_call("homeassistant", "restart", {}, blocking=False)

View File

@@ -1,10 +1,11 @@
{ {
"domain": "bahmcloud_store", "domain": "bahmcloud_store",
"name": "Bahmcloud Store", "name": "Bahmcloud Store",
"version": "0.6.8", "version": "0.7.6",
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store", "documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
"config_flow": true,
"platforms": ["update"], "platforms": ["update"],
"requirements": [], "requirements": [],
"codeowners": ["@bahmcloud"], "codeowners": ["@bahmcloud"],
"iot_class": "local_polling" "iot_class": "local_polling"
} }

View File

@@ -23,6 +23,7 @@ class BahmcloudStorePanel extends HTMLElement {
// HACS toggle (settings) // HACS toggle (settings)
this._hacsEnabled = false; this._hacsEnabled = false;
this._favoriteRepoIds = [];
this._detailRepoId = null; this._detailRepoId = null;
this._detailRepo = null; this._detailRepo = null;
@@ -56,6 +57,10 @@ class BahmcloudStorePanel extends HTMLElement {
this._versionsCache = {}; // repo_id -> [{ref,label,source}, ...] this._versionsCache = {}; // repo_id -> [{ref,label,source}, ...]
this._versionsLoadingRepoId = null; this._versionsLoadingRepoId = null;
this._selectedVersionByRepoId = {}; // repo_id -> ref ("" means latest) this._selectedVersionByRepoId = {}; // repo_id -> ref ("" means latest)
this._releaseNotesLoading = false;
this._releaseNotesText = null;
this._releaseNotesHtml = null;
this._releaseNotesError = null;
// History handling (mobile back button should go back to list, not exit panel) // History handling (mobile back button should go back to list, not exit panel)
this._historyBound = false; this._historyBound = false;
@@ -117,9 +122,15 @@ class BahmcloudStorePanel extends HTMLElement {
// Persistent settings (e.g. HACS toggle) // Persistent settings (e.g. HACS toggle)
this._hacsEnabled = !!data?.settings?.hacs_enabled; this._hacsEnabled = !!data?.settings?.hacs_enabled;
this._favoriteRepoIds = Array.isArray(data?.settings?.favorite_repo_ids)
? data.settings.favorite_repo_ids.map((x) => String(x))
: [];
// Sync settings from backend (e.g. HACS toggle) // Sync settings from backend (e.g. HACS toggle)
this._hacsEnabled = !!data?.settings?.hacs_enabled; this._hacsEnabled = !!data?.settings?.hacs_enabled;
this._favoriteRepoIds = Array.isArray(data?.settings?.favorite_repo_ids)
? data.settings.favorite_repo_ids.map((x) => String(x))
: [];
if (this._view === "detail" && this._detailRepoId && Array.isArray(data?.repos)) { if (this._view === "detail" && this._detailRepoId && Array.isArray(data?.repos)) {
const fresh = data.repos.find((r) => this._safeId(r?.id) === this._detailRepoId); const fresh = data.repos.find((r) => this._safeId(r?.id) === this._detailRepoId);
@@ -139,6 +150,9 @@ class BahmcloudStorePanel extends HTMLElement {
const resp = await this._hass.callApi("post", "bcs/settings", updates || {}); const resp = await this._hass.callApi("post", "bcs/settings", updates || {});
if (resp?.ok) { if (resp?.ok) {
this._hacsEnabled = !!resp?.settings?.hacs_enabled; this._hacsEnabled = !!resp?.settings?.hacs_enabled;
this._favoriteRepoIds = Array.isArray(resp?.settings?.favorite_repo_ids)
? resp.settings.favorite_repo_ids.map((x) => String(x))
: [];
} }
} catch (e) { } catch (e) {
// Do not fail UI for settings. // Do not fail UI for settings.
@@ -192,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);
@@ -224,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);
@@ -239,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;
@@ -253,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);
@@ -323,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;
@@ -336,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) {
@@ -442,6 +472,10 @@ class BahmcloudStorePanel extends HTMLElement {
this._readmeError = null; this._readmeError = null;
this._readmeExpanded = false; this._readmeExpanded = false;
this._readmeCanToggle = false; this._readmeCanToggle = false;
this._releaseNotesLoading = false;
this._releaseNotesText = null;
this._releaseNotesHtml = null;
this._releaseNotesError = null;
// Versions dropdown // Versions dropdown
if (!(repoId in this._selectedVersionByRepoId)) { if (!(repoId in this._selectedVersionByRepoId)) {
@@ -452,6 +486,7 @@ class BahmcloudStorePanel extends HTMLElement {
this._loadRepoDetails(repoId); this._loadRepoDetails(repoId);
this._loadReadme(repoId); this._loadReadme(repoId);
this._loadVersions(repoId); this._loadVersions(repoId);
this._loadReleaseNotes(repoId);
} }
@@ -499,6 +534,41 @@ class BahmcloudStorePanel extends HTMLElement {
} }
} }
async _loadReleaseNotes(repoId) {
if (!this._hass || !repoId) return;
this._releaseNotesLoading = true;
this._releaseNotesText = null;
this._releaseNotesHtml = null;
this._releaseNotesError = null;
this._update();
try {
const sel = this._safeText(this._selectedVersionByRepoId?.[repoId] || "").trim();
const qv = sel ? `&ref=${encodeURIComponent(sel)}` : "";
const resp = await this._hass.callApi(
"get",
`bcs/release_notes?repo_id=${encodeURIComponent(repoId)}${qv}`,
);
if (resp?.ok && typeof resp.release_notes === "string" && resp.release_notes.trim()) {
this._releaseNotesText = resp.release_notes;
this._releaseNotesHtml =
typeof resp.html === "string" && resp.html.trim() ? resp.html : null;
} else {
this._releaseNotesError =
this._safeText(resp?.message) || "Release notes not available for this version.";
}
} catch (e) {
this._releaseNotesError = e?.message
? String(e.message)
: "Release notes not available for this version.";
} finally {
this._releaseNotesLoading = false;
this._update();
}
}
async _loadReadme(repoId) { async _loadReadme(repoId) {
if (!this._hass) return; if (!this._hass) return;
this._readmeLoading = true; this._readmeLoading = true;
@@ -922,6 +992,58 @@ class BahmcloudStorePanel extends HTMLElement {
return v === true; return v === true;
} }
_isFavoriteRepo(repoId) {
const id = this._safeId(repoId);
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) {
if (!this._hass || !repoId) return;
const id = this._safeId(repoId);
const current = Array.isArray(this._favoriteRepoIds) ? this._favoriteRepoIds.slice() : [];
const next = current.includes(id)
? current.filter((x) => x !== id)
: current.concat([id]);
this._favoriteRepoIds = next;
if (Array.isArray(this._data?.repos)) {
this._data.repos = this._data.repos.map((r) => {
if (this._safeId(r?.id) !== id) return r;
return { ...r, favorite: next.includes(id) };
});
}
if (this._detailRepo && this._safeId(this._detailRepo?.id) === id) {
this._detailRepo = { ...this._detailRepo, favorite: next.includes(id) };
}
this._update();
await this._setSettings({ favorite_repo_ids: next });
await this._load();
}
_renderStore() { _renderStore() {
const repos = Array.isArray(this._data.repos) ? this._data.repos : []; const repos = Array.isArray(this._data.repos) ? this._data.repos : [];
@@ -946,11 +1068,13 @@ class BahmcloudStorePanel extends HTMLElement {
const installed = this._asBoolStrict(r?.installed); const installed = this._asBoolStrict(r?.installed);
const installedVersion = this._safeText(r?.installed_version); const installedVersion = this._safeText(r?.installed_version);
const updateAvailable = installed && !!latest && (!installedVersion || latest !== installedVersion); const updateAvailable = installed && !!latest && (!installedVersion || latest !== installedVersion);
const favorite = this._asBoolStrict(r?.favorite) || this._isFavoriteRepo(r?.id);
if (this._filter === "installed" && !installed) return false; if (this._filter === "installed" && !installed) return false;
if (this._filter === "not_installed" && installed) return false; if (this._filter === "not_installed" && installed) return false;
if (this._filter === "updates" && !updateAvailable) return false; if (this._filter === "updates" && !updateAvailable) return false;
if (this._filter === "custom" && r?.source !== "custom") return false; if (this._filter === "custom" && r?.source !== "custom") return false;
if (this._filter === "favorites" && !favorite) return false;
return true; return true;
}) })
@@ -962,16 +1086,22 @@ class BahmcloudStorePanel extends HTMLElement {
const ainstalled = this._asBoolStrict(a?.installed); const ainstalled = this._asBoolStrict(a?.installed);
const ainstalledVersion = this._safeText(a?.installed_version); const ainstalledVersion = this._safeText(a?.installed_version);
const aupdate = ainstalled && !!alatest && (!ainstalledVersion || alatest !== ainstalledVersion); const aupdate = ainstalled && !!alatest && (!ainstalledVersion || alatest !== ainstalledVersion);
const afavorite = this._asBoolStrict(a?.favorite) || this._isFavoriteRepo(a?.id);
const blatest = this._safeText(b?.latest_version); const blatest = this._safeText(b?.latest_version);
const binstalled = this._asBoolStrict(b?.installed); const binstalled = this._asBoolStrict(b?.installed);
const binstalledVersion = this._safeText(b?.installed_version); const binstalledVersion = this._safeText(b?.installed_version);
const bupdate = binstalled && !!blatest && (!binstalledVersion || blatest !== binstalledVersion); const bupdate = binstalled && !!blatest && (!binstalledVersion || blatest !== binstalledVersion);
const bfavorite = this._asBoolStrict(b?.favorite) || this._isFavoriteRepo(b?.id);
if (this._sort === "updates_first") { if (this._sort === "updates_first") {
if (aupdate !== bupdate) return aupdate ? -1 : 1; if (aupdate !== bupdate) return aupdate ? -1 : 1;
return an.localeCompare(bn); return an.localeCompare(bn);
} }
if (this._sort === "favorites_first") {
if (afavorite !== bfavorite) return afavorite ? -1 : 1;
return an.localeCompare(bn);
}
if (this._sort === "installed_first") { if (this._sort === "installed_first") {
if (ainstalled !== binstalled) return ainstalled ? -1 : 1; if (ainstalled !== binstalled) return ainstalled ? -1 : 1;
return an.localeCompare(bn); return an.localeCompare(bn);
@@ -994,6 +1124,7 @@ class BahmcloudStorePanel extends HTMLElement {
const installed = this._asBoolStrict(r?.installed); const installed = this._asBoolStrict(r?.installed);
const installedVersion = this._safeText(r?.installed_version); const installedVersion = this._safeText(r?.installed_version);
const updateAvailable = installed && !!latest && (!installedVersion || latest !== installedVersion); const updateAvailable = installed && !!latest && (!installedVersion || latest !== installedVersion);
const favorite = this._asBoolStrict(r?.favorite) || this._isFavoriteRepo(r?.id);
const badges = []; const badges = [];
// Source badges // Source badges
@@ -1001,6 +1132,7 @@ class BahmcloudStorePanel extends HTMLElement {
else if (r?.source === "hacs") badges.push("HACS"); else if (r?.source === "hacs") badges.push("HACS");
else if (r?.source === "custom") badges.push("Custom"); else if (r?.source === "custom") badges.push("Custom");
if (favorite) badges.push("Pinned");
if (installed) badges.push("Installed"); if (installed) badges.push("Installed");
if (updateAvailable) badges.push("Update"); if (updateAvailable) badges.push("Update");
@@ -1044,6 +1176,7 @@ class BahmcloudStorePanel extends HTMLElement {
</select> </select>
<select id="filter"> <select id="filter">
<option value="all" ${this._filter === "all" ? "selected" : ""}>All</option> <option value="all" ${this._filter === "all" ? "selected" : ""}>All</option>
<option value="favorites" ${this._filter === "favorites" ? "selected" : ""}>Pinned</option>
<option value="installed" ${this._filter === "installed" ? "selected" : ""}>Installed</option> <option value="installed" ${this._filter === "installed" ? "selected" : ""}>Installed</option>
<option value="not_installed" ${this._filter === "not_installed" ? "selected" : ""}>Not installed</option> <option value="not_installed" ${this._filter === "not_installed" ? "selected" : ""}>Not installed</option>
<option value="updates" ${this._filter === "updates" ? "selected" : ""}>Updates available</option> <option value="updates" ${this._filter === "updates" ? "selected" : ""}>Updates available</option>
@@ -1051,6 +1184,7 @@ class BahmcloudStorePanel extends HTMLElement {
</select> </select>
<select id="sort"> <select id="sort">
<option value="az" ${this._sort === "az" ? "selected" : ""}>AZ</option> <option value="az" ${this._sort === "az" ? "selected" : ""}>AZ</option>
<option value="favorites_first" ${this._sort === "favorites_first" ? "selected" : ""}>Pinned first</option>
<option value="updates_first" ${this._sort === "updates_first" ? "selected" : ""}>Updates first</option> <option value="updates_first" ${this._sort === "updates_first" ? "selected" : ""}>Updates first</option>
<option value="installed_first" ${this._sort === "installed_first" ? "selected" : ""}>Installed first</option> <option value="installed_first" ${this._sort === "installed_first" ? "selected" : ""}>Installed first</option>
</select> </select>
@@ -1173,6 +1307,7 @@ class BahmcloudStorePanel extends HTMLElement {
this._safeText(r?.latest_version) ? `Latest: ${this._safeText(r?.latest_version)}` : "Latest: -", this._safeText(r?.latest_version) ? `Latest: ${this._safeText(r?.latest_version)}` : "Latest: -",
this._safeText(r?.provider) ? `Provider: ${this._safeText(r?.provider)}` : null, this._safeText(r?.provider) ? `Provider: ${this._safeText(r?.provider)}` : null,
this._safeText(r?.category) ? `Category: ${this._safeText(r?.category)}` : null, this._safeText(r?.category) ? `Category: ${this._safeText(r?.category)}` : null,
this._safeText(r?.install_target) ? `Target: ${this._safeText(r?.install_target)}` : null,
this._safeText(r?.meta_author) ? `Author: ${this._safeText(r?.meta_author)}` : null, this._safeText(r?.meta_author) ? `Author: ${this._safeText(r?.meta_author)}` : null,
this._safeText(r?.meta_maintainer) ? `Maintainer: ${this._safeText(r?.meta_maintainer)}` : null, this._safeText(r?.meta_maintainer) ? `Maintainer: ${this._safeText(r?.meta_maintainer)}` : null,
this._safeText(r?.meta_source) ? `Meta: ${this._safeText(r?.meta_source)}` : null, this._safeText(r?.meta_source) ? `Meta: ${this._safeText(r?.meta_source)}` : null,
@@ -1215,7 +1350,10 @@ class BahmcloudStorePanel extends HTMLElement {
const installed = this._asBoolStrict(r?.installed); const installed = this._asBoolStrict(r?.installed);
const installedVersion = this._safeText(r?.installed_version); const installedVersion = this._safeText(r?.installed_version);
const installedDomains = Array.isArray(r?.installed_domains) ? r.installed_domains : []; const installedDomains = Array.isArray(r?.installed_domains) ? r.installed_domains : [];
const installedPaths = Array.isArray(r?.installed_paths) ? r.installed_paths : [];
const latestVersion = this._safeText(r?.latest_version); const latestVersion = this._safeText(r?.latest_version);
const favorite = this._asBoolStrict(r?.favorite) || this._isFavoriteRepo(repoId);
const installType = this._safeText(r?.install_type) || "integration";
const busyInstall = this._installingRepoId === repoId; const busyInstall = this._installingRepoId === repoId;
const busyUpdate = this._updatingRepoId === repoId; const busyUpdate = this._updatingRepoId === repoId;
@@ -1250,10 +1388,36 @@ class BahmcloudStorePanel extends HTMLElement {
</div> </div>
`; `;
const releaseNotesBlock = this._releaseNotesLoading
? `<div class="card" style="margin-top:12px;">Loading release notes...</div>`
: this._releaseNotesText
? `
<div class="card" style="margin-top:12px;">
<div class="row" style="align-items:center;">
<div><strong>Release Notes</strong></div>
<div class="muted small">${this._esc(selectedRef || latestVersion || "-")}</div>
</div>
<div id="releaseNotesPretty" class="md" style="margin-top:12px;"></div>
<details>
<summary>Show raw release notes</summary>
<div style="margin-top:10px;">
<pre class="readme">${this._esc(this._releaseNotesText)}</pre>
</div>
</details>
</div>
`
: `
<div class="card" style="margin-top:12px;">
<div><strong>Release Notes</strong></div>
<div class="muted" style="margin-top:8px;">${this._esc(this._releaseNotesError || "Release notes not available for this version.")}</div>
</div>
`;
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 = `<button class="primary" id="btnRestore" ${!installed || busy ? "disabled" : ""}>Restore</button>`; const restoreBtn = `<button class="primary" id="btnRestore" ${!installed || busy ? "disabled" : ""}>Restore</button>`;
const favoriteBtn = `<button id="btnFavorite">${favorite ? "Unpin" : "Pin"}</button>`;
const restartHint = this._restartRequired const restartHint = this._restartRequired
? ` ? `
@@ -1300,11 +1464,14 @@ class BahmcloudStorePanel extends HTMLElement {
<div><strong>Installed version:</strong> ${this._esc(installedVersion || "-")}</div> <div><strong>Installed version:</strong> ${this._esc(installedVersion || "-")}</div>
<div><strong>Latest version:</strong> ${this._esc(latestVersion || "-")}</div> <div><strong>Latest version:</strong> ${this._esc(latestVersion || "-")}</div>
<div style="margin-top:6px;"><strong>Domains:</strong> ${installedDomains.length ? this._esc(installedDomains.join(", ")) : "-"}</div> <div style="margin-top:6px;"><strong>Domains:</strong> ${installedDomains.length ? this._esc(installedDomains.join(", ")) : "-"}</div>
<div style="margin-top:6px;"><strong>Installed paths:</strong> ${installedPaths.length ? this._esc(installedPaths.join(", ")) : "-"}</div>
</div> </div>
${versionSelect} ${versionSelect}
${releaseNotesBlock}
<div class="row" style="margin-top:14px; gap:10px; flex-wrap:wrap;"> <div class="row" style="margin-top:14px; gap:10px; flex-wrap:wrap;">
${favoriteBtn}
${installBtn} ${installBtn}
${updateBtn} ${updateBtn}
${uninstallBtn} ${uninstallBtn}
@@ -1329,6 +1496,13 @@ class BahmcloudStorePanel extends HTMLElement {
const btnRestart = root.getElementById("btnRestart"); const btnRestart = root.getElementById("btnRestart");
const btnReadmeToggle = root.getElementById("btnReadmeToggle"); const btnReadmeToggle = root.getElementById("btnReadmeToggle");
const selVersion = root.getElementById("selVersion"); const selVersion = root.getElementById("selVersion");
const btnFavorite = root.getElementById("btnFavorite");
if (btnFavorite) {
btnFavorite.addEventListener("click", () => {
if (this._detailRepoId) this._toggleFavorite(this._detailRepoId);
});
}
if (btnInstall) { if (btnInstall) {
btnInstall.addEventListener("click", () => { btnInstall.addEventListener("click", () => {
@@ -1342,6 +1516,7 @@ class BahmcloudStorePanel extends HTMLElement {
if (!this._detailRepoId) return; if (!this._detailRepoId) return;
const v = selVersion.value != null ? String(selVersion.value) : ""; const v = selVersion.value != null ? String(selVersion.value) : "";
this._selectedVersionByRepoId[this._detailRepoId] = v; this._selectedVersionByRepoId[this._detailRepoId] = v;
this._loadReleaseNotes(this._detailRepoId);
}); });
} }
@@ -1380,7 +1555,22 @@ class BahmcloudStorePanel extends HTMLElement {
} }
const mount = root.getElementById("readmePretty"); const mount = root.getElementById("readmePretty");
if (!mount) return; if (!mount) {
const releaseMount = root.getElementById("releaseNotesPretty");
if (releaseMount) {
if (this._releaseNotesText) {
if (this._releaseNotesHtml) {
releaseMount.innerHTML = this._releaseNotesHtml;
this._postprocessRenderedMarkdown(releaseMount);
} else {
releaseMount.innerHTML = `<div class="muted">Rendered HTML not available. Use "Show raw release notes".</div>`;
}
} else {
releaseMount.innerHTML = "";
}
}
return;
}
if (this._readmeText) { if (this._readmeText) {
if (this._readmeHtml) { if (this._readmeHtml) {
@@ -1392,6 +1582,20 @@ class BahmcloudStorePanel extends HTMLElement {
} else { } else {
mount.innerHTML = ""; mount.innerHTML = "";
} }
const releaseMount = root.getElementById("releaseNotesPretty");
if (releaseMount) {
if (this._releaseNotesText) {
if (this._releaseNotesHtml) {
releaseMount.innerHTML = this._releaseNotesHtml;
this._postprocessRenderedMarkdown(releaseMount);
} else {
releaseMount.innerHTML = `<div class="muted">Rendered HTML not available. Use "Show raw release notes".</div>`;
}
} else {
releaseMount.innerHTML = "";
}
}
} }
_wireRestoreModal() { _wireRestoreModal() {
@@ -1459,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 `
@@ -1554,4 +1762,4 @@ class BahmcloudStorePanel extends HTMLElement {
} }
} }
customElements.define("bahmcloud-store-panel", BahmcloudStorePanel); customElements.define("bahmcloud-store-panel", BahmcloudStorePanel);

View File

@@ -189,9 +189,11 @@ async def _github_latest_version_redirect(hass: HomeAssistant, owner: str, repo:
return None, None return None, None
async def _github_latest_version_api(hass: HomeAssistant, owner: str, repo: str) -> tuple[str | None, str | None]: async def _github_latest_version_api(
hass: HomeAssistant, owner: str, repo: str, *, github_token: str | None = None
) -> tuple[str | None, str | None]:
session = async_get_clientsession(hass) session = async_get_clientsession(hass)
headers = {"Accept": "application/vnd.github+json", "User-Agent": UA} headers = _github_headers(github_token)
data, status = await _safe_json(session, f"https://api.github.com/repos/{owner}/{repo}/releases/latest", headers=headers) data, status = await _safe_json(session, f"https://api.github.com/repos/{owner}/{repo}/releases/latest", headers=headers)
if isinstance(data, dict) and data.get("tag_name"): if isinstance(data, dict) and data.get("tag_name"):
@@ -217,12 +219,14 @@ async def _github_latest_version_api(hass: HomeAssistant, owner: str, repo: str)
return None, None return None, None
async def _github_latest_version(hass: HomeAssistant, owner: str, repo: str) -> tuple[str | None, str | None]: async def _github_latest_version(
hass: HomeAssistant, owner: str, repo: str, *, github_token: str | None = None
) -> tuple[str | None, str | None]:
tag, src = await _github_latest_version_redirect(hass, owner, repo) tag, src = await _github_latest_version_redirect(hass, owner, repo)
if tag: if tag:
return tag, src return tag, src
tag, src = await _github_latest_version_api(hass, owner, repo) tag, src = await _github_latest_version_api(hass, owner, repo, github_token=github_token)
if tag: if tag:
return tag, src return tag, src
@@ -316,7 +320,16 @@ async def _gitlab_latest_version(
return None, None return None, None
async def fetch_repo_info(hass: HomeAssistant, repo_url: str) -> RepoInfo: def _github_headers(github_token: str | None = None) -> dict:
headers = {"Accept": "application/vnd.github+json", "User-Agent": UA}
token = (github_token or "").strip()
if token:
# PAT or fine-grained token
headers["Authorization"] = f"Bearer {token}"
return headers
async def fetch_repo_info(hass: HomeAssistant, repo_url: str, *, github_token: str | None = None) -> RepoInfo:
provider = detect_provider(repo_url) provider = detect_provider(repo_url)
owner, repo = _split_owner_repo(repo_url) owner, repo = _split_owner_repo(repo_url)
@@ -337,7 +350,7 @@ async def fetch_repo_info(hass: HomeAssistant, repo_url: str) -> RepoInfo:
try: try:
if provider == "github": if provider == "github":
headers = {"Accept": "application/vnd.github+json", "User-Agent": UA} headers = _github_headers(github_token)
data, status = await _safe_json(session, f"https://api.github.com/repos/{owner}/{repo}", headers=headers) data, status = await _safe_json(session, f"https://api.github.com/repos/{owner}/{repo}", headers=headers)
if isinstance(data, dict): if isinstance(data, dict):
@@ -356,7 +369,7 @@ async def fetch_repo_info(hass: HomeAssistant, repo_url: str) -> RepoInfo:
if desc: if desc:
info.description = desc info.description = desc
ver, src = await _github_latest_version(hass, owner, repo) ver, src = await _github_latest_version(hass, owner, repo, github_token=github_token)
info.latest_version = ver info.latest_version = ver
info.latest_version_source = src info.latest_version_source = src
return info return info
@@ -514,6 +527,7 @@ async def fetch_repo_versions(
provider: str | None = None, provider: str | None = None,
default_branch: str | None = None, default_branch: str | None = None,
limit: int = 20, limit: int = 20,
github_token: str | None = None,
) -> list[dict[str, str]]: ) -> list[dict[str, str]]:
"""List available versions/refs for a repository. """List available versions/refs for a repository.
@@ -523,7 +537,7 @@ async def fetch_repo_versions(
- source: release|tag|branch - source: release|tag|branch
Notes: Notes:
- Uses public endpoints (no tokens) for public repositories. - Uses provider APIs; for GitHub we include the configured token (if any) to avoid unauthenticated rate limits.
- We prefer releases first (if available), then tags. - We prefer releases first (if available), then tags.
""" """
@@ -541,6 +555,8 @@ async def fetch_repo_versions(
session = async_get_clientsession(hass) session = async_get_clientsession(hass)
headers = {"User-Agent": UA} headers = {"User-Agent": UA}
if prov == "github":
headers = _github_headers(github_token)
out: list[dict[str, str]] = [] out: list[dict[str, str]] = []
seen: set[str] = set() seen: set[str] = set()
@@ -559,11 +575,13 @@ async def fetch_repo_versions(
try: try:
if prov == "github": if prov == "github":
# Releases # Releases (prefer these over tags)
gh_headers = {"Accept": "application/vnd.github+json", "User-Agent": UA} # Use the configured GitHub token (if any) to avoid unauthenticated rate limits.
gh_headers = _github_headers(github_token)
per_page = max(1, min(int(limit), 100))
data, _ = await _safe_json( data, _ = await _safe_json(
session, session,
f"https://api.github.com/repos/{owner}/{repo}/releases?per_page={int(limit)}", f"https://api.github.com/repos/{owner}/{repo}/releases?per_page={per_page}",
headers=gh_headers, headers=gh_headers,
) )
if isinstance(data, list): if isinstance(data, list):
@@ -581,7 +599,7 @@ async def fetch_repo_versions(
# Tags # Tags
data, _ = await _safe_json( data, _ = await _safe_json(
session, session,
f"https://api.github.com/repos/{owner}/{repo}/tags?per_page={int(limit)}", f"https://api.github.com/repos/{owner}/{repo}/tags?per_page={per_page}",
headers=gh_headers, headers=gh_headers,
) )
if isinstance(data, list): if isinstance(data, list):
@@ -660,4 +678,78 @@ async def fetch_repo_versions(
except Exception: except Exception:
_LOGGER.debug("fetch_repo_versions failed for %s", repo_url, exc_info=True) _LOGGER.debug("fetch_repo_versions failed for %s", repo_url, exc_info=True)
return out return out
async def fetch_release_notes_markdown(
hass: HomeAssistant,
repo_url: str,
*,
ref: str | None,
provider: str | None = None,
github_token: str | None = None,
) -> str | None:
"""Fetch release notes for a specific release tag."""
repo_url = (repo_url or "").strip()
target_ref = (ref or "").strip()
if not repo_url or not target_ref:
return None
prov = (provider or "").strip().lower() if provider else ""
if not prov:
prov = detect_provider(repo_url)
owner, repo = _split_owner_repo(repo_url)
if not owner or not repo:
return None
session = async_get_clientsession(hass)
try:
if prov == "github":
data, status = await _safe_json(
session,
f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{quote_plus(target_ref)}",
headers=_github_headers(github_token),
)
if status == 200 and isinstance(data, dict):
body = data.get("body")
if isinstance(body, str) and body.strip():
return body
return None
if prov == "gitlab":
u = urlparse(repo_url.rstrip("/"))
base = f"{u.scheme}://{u.netloc}"
project = quote_plus(f"{owner}/{repo}")
data, status = await _safe_json(
session,
f"{base}/api/v4/projects/{project}/releases/{quote_plus(target_ref)}",
headers={"User-Agent": UA},
)
if status == 200 and isinstance(data, dict):
body = data.get("description")
if isinstance(body, str) and body.strip():
return body
return None
u = urlparse(repo_url.rstrip("/"))
base = f"{u.scheme}://{u.netloc}"
data, status = await _safe_json(
session,
f"{base}/api/v1/repos/{owner}/{repo}/releases/tags/{quote_plus(target_ref)}",
headers={"User-Agent": UA},
)
if status == 200 and isinstance(data, dict):
body = data.get("body")
if isinstance(body, str) and body.strip():
return body
note = data.get("note")
if isinstance(note, str) and note.strip():
return note
return None
except Exception:
_LOGGER.debug("fetch_release_notes_markdown failed for %s ref=%s", repo_url, target_ref, exc_info=True)
return None

View File

@@ -25,6 +25,8 @@ class InstalledRepo:
url: str url: str
domains: list[str] domains: list[str]
installed_at: int installed_at: int
install_type: str = "integration"
installed_paths: list[str] | None = None
installed_version: str | None = None # BCS "installed ref" (tag/release/branch) installed_version: str | None = None # BCS "installed ref" (tag/release/branch)
installed_manifest_version: str | None = None # informational only installed_manifest_version: str | None = None # informational only
ref: str | None = None # kept for backward compatibility / diagnostics ref: str | None = None # kept for backward compatibility / diagnostics
@@ -197,6 +199,11 @@ class BCSStorage:
if not isinstance(domains, list): if not isinstance(domains, list):
domains = [] domains = []
domains = [str(d) for d in domains if str(d).strip()] domains = [str(d) for d in domains if str(d).strip()]
installed_paths = entry.get("installed_paths") or []
if not isinstance(installed_paths, list):
installed_paths = []
installed_paths = [str(p) for p in installed_paths if str(p).strip()]
install_type = str(entry.get("install_type") or "integration").strip() or "integration"
installed_version = entry.get("installed_version") installed_version = entry.get("installed_version")
ref = entry.get("ref") ref = entry.get("ref")
@@ -213,6 +220,8 @@ class BCSStorage:
url=str(entry.get("url") or ""), url=str(entry.get("url") or ""),
domains=domains, domains=domains,
installed_at=int(entry.get("installed_at") or 0), installed_at=int(entry.get("installed_at") or 0),
install_type=install_type,
installed_paths=installed_paths,
installed_version=str(installed_version) if installed_version else None, installed_version=str(installed_version) if installed_version else None,
installed_manifest_version=str(installed_manifest_version) if installed_manifest_version else None, installed_manifest_version=str(installed_manifest_version) if installed_manifest_version else None,
ref=str(ref) if ref else None, ref=str(ref) if ref else None,
@@ -238,9 +247,11 @@ class BCSStorage:
repo_id: str, repo_id: str,
url: str, url: str,
domains: list[str], domains: list[str],
installed_version: str | None, installed_version: str | None = None,
installed_manifest_version: str | None = None, installed_manifest_version: str | None = None,
ref: str | None, ref: str | None = None,
install_type: str = "integration",
installed_paths: list[str] | None = None,
) -> None: ) -> None:
data = await self._load() data = await self._load()
installed = data.get("installed_repos", {}) installed = data.get("installed_repos", {})
@@ -252,6 +263,8 @@ class BCSStorage:
"repo_id": str(repo_id), "repo_id": str(repo_id),
"url": str(url), "url": str(url),
"domains": [str(d) for d in (domains or []) if str(d).strip()], "domains": [str(d) for d in (domains or []) if str(d).strip()],
"install_type": str(install_type or "integration"),
"installed_paths": [str(p) for p in (installed_paths or []) if str(p).strip()],
"installed_at": int(time.time()), "installed_at": int(time.time()),
# IMPORTANT: this is what BCS uses as "installed version" (ref/tag/branch) # IMPORTANT: this is what BCS uses as "installed version" (ref/tag/branch)
"installed_version": installed_version, "installed_version": installed_version,
@@ -268,4 +281,4 @@ class BCSStorage:
if isinstance(installed, dict) and repo_id in installed: if isinstance(installed, dict) and repo_id in installed:
installed.pop(repo_id, None) installed.pop(repo_id, None)
data["installed_repos"] = installed data["installed_repos"] = installed
await self._save(data) await self._save(data)

View File

@@ -1,4 +1,29 @@
{ {
"config": {
"abort": {
"single_instance_allowed": "Only one Bahmcloud Store instance can be configured."
},
"step": {
"user": {
"title": "Bahmcloud Store",
"description": "Bahmcloud Store uses a fixed official store index. You can optionally add a GitHub token to increase API rate limits.",
"data": {
"github_token": "GitHub token (optional)"
}
}
}
},
"options": {
"step": {
"init": {
"title": "Bahmcloud Store Options",
"description": "Optionally set or clear your GitHub token to reduce rate limiting.",
"data": {
"github_token": "GitHub token (optional)"
}
}
}
},
"issues": { "issues": {
"restart_required": { "restart_required": {
"title": "Restart required", "title": "Restart required",
@@ -15,4 +40,4 @@
} }
} }
} }
} }

View File

@@ -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,12 +10,23 @@ 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 .core import DOMAIN, SIGNAL_UPDATED, BCSCore from .const import DOMAIN
from .core import SIGNAL_UPDATED, BCSCore
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def _get_core(hass: HomeAssistant) -> BCSCore | None:
data = hass.data.get(DOMAIN)
if isinstance(data, dict):
c = data.get("_core")
return c if isinstance(c, BCSCore) else None
# Backwards compatibility (older versions used hass.data[DOMAIN] = core)
return data if isinstance(data, BCSCore) else None
def _pretty_repo_name(core: BCSCore, repo_id: str) -> str: def _pretty_repo_name(core: BCSCore, repo_id: str) -> str:
"""Return a human-friendly name for a repo update entity.""" """Return a human-friendly name for a repo update entity."""
try: try:
@@ -117,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
@@ -146,7 +186,7 @@ async def async_setup_platform(
discovery_info=None, discovery_info=None,
): ):
"""Set up BCS update entities.""" """Set up BCS update entities."""
core: BCSCore | None = hass.data.get(DOMAIN) core: BCSCore | None = _get_core(hass)
if not core: if not core:
_LOGGER.debug("BCS core not available, skipping update platform setup") _LOGGER.debug("BCS core not available, skipping update platform setup")
return return
@@ -159,4 +199,13 @@ async def async_setup_platform(
def _handle_update() -> None: def _handle_update() -> None:
_sync_entities(core, entities, async_add_entities) _sync_entities(core, entities, async_add_entities)
async_dispatcher_connect(hass, SIGNAL_UPDATED, _handle_update) async_dispatcher_connect(hass, SIGNAL_UPDATED, _handle_update)
async def async_setup_entry(
hass: HomeAssistant,
entry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up BCS update entities from a config entry."""
await async_setup_platform(hass, {}, async_add_entities, None)

View File

@@ -275,6 +275,9 @@ class BCSSettingsView(HomeAssistantView):
updates: dict[str, Any] = {} updates: dict[str, Any] = {}
if "hacs_enabled" in data: if "hacs_enabled" in data:
updates["hacs_enabled"] = bool(data.get("hacs_enabled")) updates["hacs_enabled"] = bool(data.get("hacs_enabled"))
if "favorite_repo_ids" in data:
raw = data.get("favorite_repo_ids") or []
updates["favorite_repo_ids"] = raw if isinstance(raw, list) else []
try: try:
settings = await self.core.set_settings(updates) settings = await self.core.set_settings(updates)
@@ -349,6 +352,41 @@ class BCSVersionsView(HomeAssistantView):
return web.json_response({"ok": False, "message": str(e) or "List versions failed"}, status=500) return web.json_response({"ok": False, "message": str(e) or "List versions failed"}, status=500)
class BCSReleaseNotesView(HomeAssistantView):
url = "/api/bcs/release_notes"
name = "api:bcs_release_notes"
requires_auth = True
def __init__(self, core: Any) -> None:
self.core: BCSCore = core
async def get(self, request: web.Request) -> web.Response:
repo_id = request.query.get("repo_id")
if not repo_id:
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
ref = request.query.get("ref")
ref = str(ref).strip() if ref is not None else None
try:
notes = await self.core.fetch_release_notes_markdown(repo_id, ref=ref)
if not notes or not str(notes).strip():
return web.json_response(
{"ok": False, "message": "Release notes not found for this version."},
status=404,
)
notes_str = str(notes)
html = _render_markdown_server_side(notes_str)
return web.json_response(
{"ok": True, "ref": ref, "release_notes": notes_str, "html": html},
status=200,
)
except Exception as e:
_LOGGER.exception("BCS release notes failed: %s", e)
return web.json_response({"ok": False, "message": str(e) or "Release notes failed"}, status=500)
class BCSInstallView(HomeAssistantView): class BCSInstallView(HomeAssistantView):
url = "/api/bcs/install" url = "/api/bcs/install"
name = "api:bcs_install" name = "api:bcs_install"
@@ -502,6 +540,9 @@ class BCSRepoDetailView(HomeAssistantView):
domains = inst.get("domains") or [] domains = inst.get("domains") or []
if not isinstance(domains, list): if not isinstance(domains, list):
domains = [] domains = []
installed_paths = inst.get("installed_paths") or []
if not isinstance(installed_paths, list):
installed_paths = []
return web.json_response({ return web.json_response({
"ok": True, "ok": True,
@@ -518,13 +559,18 @@ class BCSRepoDetailView(HomeAssistantView):
"latest_version": repo.latest_version, "latest_version": repo.latest_version,
"latest_version_source": repo.latest_version_source, "latest_version_source": repo.latest_version_source,
"category": repo.meta_category, "category": repo.meta_category,
"category_key": self.core._repo_install_type(repo),
"install_target": self.core._repo_install_target(repo),
"meta_author": repo.meta_author, "meta_author": repo.meta_author,
"meta_maintainer": repo.meta_maintainer, "meta_maintainer": repo.meta_maintainer,
"meta_source": repo.meta_source, "meta_source": repo.meta_source,
"installed": installed, "installed": installed,
"install_type": inst.get("install_type") or self.core._repo_install_type(repo),
"installed_version": inst.get("installed_version"), "installed_version": inst.get("installed_version"),
"installed_manifest_version": inst.get("installed_manifest_version"), "installed_manifest_version": inst.get("installed_manifest_version"),
"installed_domains": domains, "installed_domains": domains,
"installed_paths": installed_paths,
"favorite": self.core.is_favorite_repo(repo.id),
} }
}, status=200) }, status=200)
except Exception as e: except Exception as e: