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
.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)
### Fixed

View File

@@ -1,50 +1,86 @@
# Bahmcloud Store (BCS) for Home Assistant
Bahmcloud Store (BCS) is a provider-neutral store for Home Assistant custom integrations.
It allows you to browse, install, update, downgrade, uninstall and restore integrations
directly from the Home Assistant UI.
Bahmcloud Store (BCS) is a provider-neutral store for Home Assistant content hosted on git platforms.
It currently supports installing and managing:
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
### Option A: Home Assistant OS / Supervised (Add-on recommended)
### Option A: Home Assistant OS / Supervised
1. Open **Settings Add-ons Add-on Store**
2. Add the Bahmcloud Add-on repository
1. Open **Settings -> Add-ons -> Add-on Store**
2. Add the Bahmcloud add-on repository:
`https://git.bahmcloud.de/bahmcloud/addons`
3. Install **Bahmcloud Store Installer**
4. Start the add-on
5. Restart Home Assistant
Installed to:
/config/custom_components/bahmcloud_store
`/config/custom_components/bahmcloud_store`
---
### Option B: Manual Installation (Container / Core)
### Option B: Manual Installation
1. Download the latest release
2. Copy `custom_components/bahmcloud_store` to:
/config/custom_components/bahmcloud_store
`/config/custom_components/bahmcloud_store`
3. Restart Home Assistant
---
## Repository Sources
- **BCS Official**
- **HACS**
- **Custom**
- **BCS Official**: repositories from the Bahmcloud store index
- **HACS**: optional official HACS integration source
- **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
Developer documentation:
https://git.bahmcloud.de/bahmcloud/bahmcloud_store/src/branch/main/README_DEVELOPER.md
Full technical documentation:
https://git.bahmcloud.de/bahmcloud/bahmcloud_store/src/branch/main/README_FULL.md
- Developer documentation: `README_DEVELOPER.md`
- Full user guide: `README_FULL.md`

View File

@@ -1,115 +1,159 @@
# Bahmcloud Store Developer Documentation
# Bahmcloud Store - Developer Documentation
For contributors and maintainers.
## Architecture
## Project Scope
Repositories:
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```
Bahmcloud Store is evolving from an integration-only store into a broader Home Assistant git-based content store.
### Integration Layout
Currently supported install categories:
custom_components/bahmcloud_store/
- __init__.py: setup, panel registration, schedule background after HA started
- core.py: index merge, enrichment, install/update/uninstall, backups, restore, caching
- providers.py: GitHub/GitLab/Gitea repo info + latest version helpers
- metadata.py: read bcs.yaml / hacs.json / hacs.yaml
- storage.py: persistent storage (installed, custom, repo cache, hacs cache)
- views.py: HTTP API endpoints
- update.py: UpdateEntity implementation
- repairs.py: (optional) Repairs flow for restart
- panel/: UI (panel.js, styles.css, etc.)
- manifest.json
- Integrations
- Blueprints
Planned categories:
- Templates
- Lovelace / dashboard designs
- Additional content types with category-specific install logic
## Repository Layout
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
- RepoItem (merged)
- Installed repos (storage)
- Repo cache (persisted enrichment)
- HACS meta cache (mapping owner/repo → name/description)
## 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
- `RepoItem`: merged repository model used by the UI and backend
- Installed repositories: persisted in Home Assistant storage
- Settings: persistent UI and behavior settings such as HACS enablement and pinned repositories
- Repo cache: provider and metadata enrichment cache
- HACS cache: display metadata cache for HACS integration repositories
## Metadata
- Prefer `bcs.yaml`, fallback `hacs.json` / `hacs.yaml`
- Populate name/description/category/author/maintainer
Metadata priority:
## 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
- name: Your Integration Name
url: https://your-git-hoster.com/your-org/your-repo
category: Category (actually only "Integrations" are supported)
name: Example Project
description: Short description
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
name: Your Integration Name
description: One-liner for the store (optional, store information are also catched from git repository)
category: Integrations (actually only supported)
author: Your Name
maintainer: Your Handle
- name: Example Project
url: https://your-git-hoster.example/org/repo
category: Blueprint
```
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
- Respect provider rate limits
- Clean logging around refresh/install/update/restore
- Keep UI responsive; throttle updates
```yaml
name: Example Project
description: One-line description
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"
- Add Downloads and install for category "Template"
- Add Downloads and install for category "Theme"
- Add Downloads and install for category "Blueprint"
-
## Design Direction
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.
It is written for users and admins who want a complete, practical reference.
This guide explains the current feature set of Bahmcloud Store (BCS) for Home Assistant.
> 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
- Concepts
- Sources (BCS / HACS / Custom)
- Sources
- Supported Content Types
- UI Overview
- Finding Integrations
- Finding Repositories
- Installing
- Selecting Versions / Downgrading
- Selecting Versions
- Updating
- Uninstalling
- Backups & Restore
- Pinned Repositories
- Release Notes
- Backups and Restore
- Custom Repositories
- HACS Repositories
- Update Entities in Home Assistant
- Background Caching & Performance
- Restart Required
- Update Entities
- Performance and Caching
- Restart Behavior
- Troubleshooting
- FAQ
@@ -29,189 +32,293 @@ It is written for users and admins who want a complete, practical reference.
## Concepts
- **Sources**:
- **BCS Official** → entries from index (`store.yaml`) made and added from Bahmcloud
- **HACS** → official HACS integrations list (toggleable)
- **Custom** → manual entries you add locally
- **Install location**: `/config/custom_components/<domain>`
- **Backup**: BCS keeps preupdate copies in `/config/.bcs_backups/<domain>/<timestamp>/`
### Sources
- **BCS Official**: repositories from the official Bahmcloud index
- **HACS**: optional HACS integration source
- **Custom**: repositories you add manually by URL
### 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**:
- **BCS Official** from Bahmcloud index
- **HACS** from HACS official list (enable with the toggle)
- **Custom** added by you
### Integrations
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
Top bar:
- **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)
Store view:
Repository card:
- Name, description, badges (source, installed/update), category
- Buttons: **Install / Update / Uninstall**
- **Readme** expandable
- **Open** to see details (available versions, metadata)
- Search
- Source filter
- Category filter
- State filter
- 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.
2. Combine with **Category** and **Source**.
3. Sort to surface desired results.
1. Use **Search** to filter by name, description, or URL.
2. Use **Source** to switch between BCS Official, HACS, and Custom repositories.
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
1. Open a repository.
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)
### Integration Install
**What happens internally**
- BCS downloads the repository ZIP for the selected version (release/tag/branch).
- It extracts all integrations found under `custom_components/<domain>` and deploys them.
- It saves the **installed version (ref)** to track updates reliably, even if the repos own `manifest.json` is wrong/outdated.
1. Open a repository.
2. Optionally select a version.
3. Click **Install**.
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.
- Choose **Latest** or a previous **release/tag**.
- Installing a chosen ref **pins** the integration to that ref (no surprise updates).
- You can upgrade again later by selecting **Latest** and clicking **Update**.
- Use the version selector in the detail view.
- `Latest` uses the newest provider release or tag BCS can determine.
- You can install an older release or tag when available.
- Release notes are shown when the selected version is a provider release with a body.
---
## Updating
- The **Update** button appears when `latest_version` differs from your **installed version (ref)**.
- Updates are also available via **Home Assistant → Settings → Updates** (native Update entity).
- Clicking **Update** runs the same safe pipeline as **Install** (with backup).
### Integrations
**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
- Click **Uninstall** on the repository.
- BCS removes the integration folders under `custom_components/<domain>`.
- The installed state is cleared in the Store.
- Restart Home Assistant if prompted.
### Integrations
- Removes the installed integration directories from `/config/custom_components`
- 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.
```
/config/.bcs_backups/<domain>/<timestamp>/
```
Current behavior:
**Restore**:
1. Open the repository in store.
2. Select **Restore…**.
3. Pick one of the **last backups** (up to retention limit). (Version details are showed in ())
4. Confirm BCS restores files and reconciles installed version to the restored ref.
5. Restart Home Assistant if prompted.
- Pin or unpin from the detail view
- Filter the store by pinned repositories
- Sort the store with pinned repositories first
- Pinned repositories show a dedicated badge in the store overview
- Pinned state is persisted in settings
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
You can add any public repository (GitHub/GitLab/Gitea/Any other Git Hoster). BCS will attempt to detect:
- provider & default branch
- latest version (release/tag/atom)
- repo metadata (prefer `bcs.yaml`, fallback `hacs.json/hacs.yaml`)
- readme (common filenames)
You can add any public repository URL that matches a supported provider style and contains supported content.
**Add an custom repository**(typical flows):
- 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
BCS attempts to detect:
**Remove an custom repository**(typical flows):
- From the Store UI
1. If you start up Bahmcloud Store, you are on tab "Store"
2. Go to tab "Manage"
3. Your repository you want to remove (if its custom) show up in the list
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/*```))
- provider
- default branch
- latest version
- metadata from `bcs.yaml`, `hacs.yaml`, or `hacs.json`
- README content
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
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.
- HACS entries are **not** part of your BCS-Official Repositories (avoid duplicate entries).
Current scope:
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:
- Found under **Settings → Updates**
- Clicking **Install** triggers BCS update pipeline
- Shows **installed** and **latest** versions (BCS ref logic)
BCS exposes Home Assistant update entities for installed integrations.
- Found under **Settings -> Updates**
- 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
- **Background enrichment**: provider info, latest version, metadata, description, readme (best effort)
- **Ondemand**: opening a repo triggers immediate enrichment; data is **persisted** to cache
- **Persistent cache**: survives HA restarts; speeds up subsequent runs
- **Refresh**: immediately rechecks installed repos and key metadata
BCS is designed to keep startup responsive.
- Heavy work runs after Home Assistant startup
- Repo data is enriched in the background
- 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
- **New release but no update**: Open the repo detail once; ensure its a **release/tag** (commits alone dont change the ref).
- **Descriptions/Latest missing**: Wait for background enrichment or open the repo detail (forces enrichment). Cached afterwards.
(You also can Install new version by selecting in Version tab)
- **Slow startup**: BCS schedules heavy work after HA started. Keep indexes reasonable.
- **Latest version missing**: open the repository detail once to force a recheck
- **Release notes missing**: the selected version may be a tag or branch without a provider release body
- **Blueprint install fails**: verify the repository really contains a `blueprints/` folder
- **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
- **Backups path?** `/config/.bcs_backups/<domain>/<timestamp>/`
- **Install path?** `/config/custom_components/<domain>`
- **Downgrade?** Yes, pick an older version and install.
- **Restart needed?** Yes, after install/update/restore.
### Where are integrations installed?
`/config/custom_components/<domain>`
### 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
from datetime import timedelta
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
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.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__)
DOMAIN = "bahmcloud_store"
DEFAULT_STORE_URL = "https://git.bahmcloud.de/bahmcloud/ha_store/raw/branch/main/store.yaml"
CONF_STORE_URL = "store_url"
PLATFORMS: list[str] = ["update"]
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
cfg = config.get(DOMAIN, {}) or {}
store_url = cfg.get(CONF_STORE_URL, DEFAULT_STORE_URL)
"""Set up Bahmcloud Store.
core = BCSCore(hass, BCSConfig(store_url=store_url))
hass.data[DOMAIN] = core
We intentionally do NOT support YAML configuration.
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()
# Provide native Update entities in Settings -> System -> Updates.
# This integration is YAML-based (async_setup), therefore we load the platform manually.
await async_load_platform(hass, "update", DOMAIN, {}, config)
# HTTP views + panel (registered once per entry; we only allow one entry).
from .views import (
StaticAssetsView,
BCSApiView,
BCSSettingsView,
BCSReadmeView,
BCSVersionsView,
BCSReleaseNotesView,
BCSRepoDetailView,
BCSCustomRepoView,
BCSInstallView,
@@ -53,6 +75,7 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
hass.http.register_view(BCSSettingsView(core))
hass.http.register_view(BCSReadmeView(core))
hass.http.register_view(BCSVersionsView(core))
hass.http.register_view(BCSReleaseNotesView(core))
hass.http.register_view(BCSRepoDetailView(core))
hass.http.register_view(BCSCustomRepoView(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",
webcomponent_name="bahmcloud-store-panel",
# 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_icon="mdi:store",
require_admin=True,
@@ -80,7 +103,7 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
except BCSError as 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:
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)
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 .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
_LOGGER = logging.getLogger(__name__)
@@ -58,6 +65,7 @@ class BCSInstallError(BCSError):
@dataclass
class BCSConfig:
store_url: str
github_token: str | None = None
@dataclass
@@ -110,7 +118,7 @@ class BCSCore:
self._installed_cache: dict[str, Any] = {}
# 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.
self._hacs_meta_fetched_at: int = 0
@@ -180,6 +188,108 @@ class BCSCore:
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:
self._listeners.append(cb)
@@ -238,24 +348,52 @@ class BCSCore:
def get_settings_public(self) -> dict[str, Any]:
"""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 {
"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]:
"""Persist settings and apply them."""
safe_updates: dict[str, Any] = {}
reload_required = False
if "hacs_enabled" in (updates or {}):
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)
if isinstance(merged, dict):
self.settings.update(merged)
# Reload repo list after changing settings.
await self.full_refresh(source="settings")
if reload_required:
await self.full_refresh(source="settings")
else:
self.signal_updated()
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:
index_repos, refresh_seconds = await self._load_index_repos()
self.refresh_seconds = refresh_seconds
@@ -323,6 +461,12 @@ class BCSCore:
# Apply persisted per-repo enrichment cache (instant UI after restart).
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)
self.repos = merged
@@ -696,7 +840,7 @@ class BCSCore:
async def process_one(r: RepoItem) -> None:
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.owner = info.owner or r.owner
@@ -755,7 +899,7 @@ class BCSCore:
async def _enrich_one_repo(self, r: RepoItem) -> None:
"""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.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)
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)
if not repo:
return []
# 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_ts = 0
async with self._repo_cache_lock:
@@ -822,11 +974,27 @@ class BCSCore:
cached_ts = int(cached.get("versions_ts", 0) or 0)
now = int(time.time())
if isinstance(cached, dict) and cached.get("versions") and (now - cached_ts) < VERSIONS_CACHE_TTL_SECONDS:
return list(cached.get("versions") or [])
cached_versions = list(cached.get("versions") or []) if isinstance(cached, dict) else []
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:
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:
versions = []
@@ -1004,13 +1172,18 @@ class BCSCore:
"latest_version": r.latest_version,
"latest_version_source": r.latest_version_source,
"category": r.meta_category,
"category_key": self._repo_install_type(r),
"install_target": self._repo_install_target(r),
"meta_author": r.meta_author,
"meta_maintainer": r.meta_maintainer,
"meta_source": r.meta_source,
"installed": installed,
"install_type": str(inst.get("install_type") if isinstance(inst, dict) else self._repo_install_type(r)),
"installed_version": installed_version,
"installed_manifest_version": installed_manifest_version,
"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
@@ -1027,21 +1200,21 @@ class BCSCore:
default_branch=repo.default_branch,
)
async def list_repo_versions(self, repo_id: str, *, limit: int = 20) -> list[dict[str, str]]:
"""List installable versions/refs for a repo.
This is used by the UI to allow selecting an older tag/release.
"""
async def fetch_release_notes_markdown(self, repo_id: str, ref: str | None = None) -> str | None:
repo = self.get_repo(repo_id)
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,
repo.url,
ref=target_ref,
provider=repo.provider,
default_branch=repo.default_branch,
limit=limit,
github_token=self.config.github_token,
)
def _pick_ref_for_install(self, repo: RepoItem) -> str:
@@ -1051,6 +1224,32 @@ class BCSCore:
return str(repo.default_branch).strip()
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:
ref = (ref or "").strip()
if not ref:
@@ -1117,6 +1316,18 @@ class BCSCore:
return candidate
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:
"""Create backup root directory if needed."""
def _mkdir() -> None:
@@ -1124,6 +1335,11 @@ class BCSCore:
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]:
"""Build metadata for backup folders so restores can recover the stored version."""
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)
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:
"""Restore a domain folder from a backup."""
dest_root = Path(self.hass.config.path("custom_components"))
@@ -1217,6 +1495,37 @@ class BCSCore:
await self.hass.async_add_executor_job(_restore)
_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]]:
"""List available backup sets for an installed repository.
@@ -1227,6 +1536,30 @@ class BCSCore:
domains of the repository.
"""
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 []
if not isinstance(domains, list) or not domains:
return []
@@ -1278,6 +1611,53 @@ class BCSCore:
if not inst:
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 []
if not isinstance(domains, list) or not domains:
raise BCSInstallError("No installed domains found")
@@ -1374,6 +1754,17 @@ class BCSCore:
except Exception:
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:
"""Best-effort: read manifest.json version from a legacy backup (no metadata)."""
@@ -1471,15 +1862,25 @@ class BCSCore:
for it in items:
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
# exists and contains a manifest.json.
present = False
for d in domains:
p = cc_root / d
if p.is_dir() and (p / "manifest.json").exists():
present = True
break
if install_type == "integration":
for d in domains:
p = cc_root / d
if p.is_dir() and (p / "manifest.json").exists():
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:
to_remove.append(it.repo_id)
@@ -1488,6 +1889,8 @@ class BCSCore:
cache[it.repo_id] = {
"installed": True,
"domains": domains,
"install_type": install_type,
"installed_paths": installed_paths,
"installed_version": it.installed_version,
"installed_manifest_version": it.installed_manifest_version,
"ref": it.ref,
@@ -1523,24 +1926,50 @@ class BCSCore:
if path.exists() and path.is_dir():
shutil.rmtree(path, ignore_errors=True)
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)
def _remove_file(path: Path) -> None:
if path.exists() and path.is_file():
path.unlink(missing_ok=True)
def _prune_empty_parents(path: Path, stop_at: Path) -> None:
cur = path.parent
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._refresh_installed_cache()
# Show restart required in Settings.
if removed:
if removed and install_type == "integration":
self._mark_restart_required()
_LOGGER.info("BCS uninstall complete: repo_id=%s removed_domains=%s", repo_id, removed)
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]:
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)
installed_domains: list[str] = []
installed_paths: list[str] = []
backups: dict[str, Path] = {}
content_backup: Path | None = None
install_type = self._repo_install_type(repo)
inst_before = self.get_installed(repo_id) or {}
backup_meta = {
@@ -1576,57 +2008,97 @@ class BCSCore:
await self._download_zip(zip_url, zip_path)
await self._extract_zip(zip_path, extract_dir)
cc_root = self._find_custom_components_root(extract_dir)
if not cc_root:
raise BCSInstallError("custom_components folder not found in repository ZIP")
if install_type == "blueprint":
blueprints_root = self._find_blueprints_root(extract_dir)
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():
if not domain_dir.is_dir():
continue
manifest = domain_dir / "manifest.json"
if not manifest.exists():
continue
for src in blueprints_root.rglob("*"):
if not src.is_file():
continue
rel = src.relative_to(blueprints_root)
planned_paths.append(str(Path("blueprints") / rel).replace("\\", "/"))
domain = domain_dir.name
target = dest_root / domain
if not planned_paths:
raise BCSInstallError("No blueprint files found under blueprints/")
# 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)
m = dict(backup_meta)
m["install_type"] = "blueprint"
m["installed_paths"] = planned_paths
content_backup = await self._backup_paths(repo_id, planned_paths, meta=m)
await self._copy_domain_dir(domain_dir, domain)
installed_domains.append(domain)
def _copy_blueprints() -> list[str]:
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:
raise BCSInstallError("No integrations found under custom_components/ (missing manifest.json)")
installed_paths = await self.hass.async_add_executor_job(_copy_blueprints)
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
await self.storage.set_installed_repo(
repo_id=repo_id,
url=repo.url,
domains=installed_domains,
install_type=install_type,
installed_paths=installed_paths,
installed_version=installed_version,
installed_manifest_version=installed_manifest_version,
ref=ref,
)
await self._refresh_installed_cache()
self._mark_restart_required()
if install_type == "integration":
self._mark_restart_required()
_LOGGER.info(
"BCS install complete: repo_id=%s domains=%s installed_ref=%s manifest_version=%s",
repo_id,
installed_domains,
installed_domains if installed_domains else installed_paths,
installed_version,
installed_manifest_version,
)
@@ -1635,9 +2107,11 @@ class BCSCore:
"ok": True,
"repo_id": repo_id,
"domains": installed_domains,
"installed_paths": installed_paths,
"install_type": install_type,
"installed_version": installed_version,
"installed_manifest_version": installed_manifest_version,
"restart_required": True,
"restart_required": True if install_type == "integration" else False,
}
except Exception as e:
@@ -1653,6 +2127,12 @@ class BCSCore:
except Exception:
_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.
for domain in created_new:
try:
@@ -1674,4 +2154,4 @@ class BCSCore:
return await self.install_repo(repo_id, version=version)
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",
"name": "Bahmcloud Store",
"version": "0.6.8",
"version": "0.7.6",
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
"config_flow": true,
"platforms": ["update"],
"requirements": [],
"codeowners": ["@bahmcloud"],
"iot_class": "local_polling"
}
}

View File

@@ -23,6 +23,7 @@ class BahmcloudStorePanel extends HTMLElement {
// HACS toggle (settings)
this._hacsEnabled = false;
this._favoriteRepoIds = [];
this._detailRepoId = null;
this._detailRepo = null;
@@ -56,6 +57,10 @@ class BahmcloudStorePanel extends HTMLElement {
this._versionsCache = {}; // repo_id -> [{ref,label,source}, ...]
this._versionsLoadingRepoId = null;
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)
this._historyBound = false;
@@ -117,9 +122,15 @@ class BahmcloudStorePanel extends HTMLElement {
// Persistent settings (e.g. HACS toggle)
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)
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)) {
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 || {});
if (resp?.ok) {
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) {
// Do not fail UI for settings.
@@ -192,7 +206,9 @@ class BahmcloudStorePanel extends HTMLElement {
this._error = this._safeText(resp?.message) || "Install failed.";
} else {
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) {
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.";
} else {
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) {
this._error = e?.message ? String(e.message) : String(e);
@@ -239,7 +257,11 @@ class BahmcloudStorePanel extends HTMLElement {
if (!repoId) 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;
this._uninstallingRepoId = repoId;
@@ -253,7 +275,9 @@ class BahmcloudStorePanel extends HTMLElement {
this._error = this._safeText(resp?.message) || "Uninstall failed.";
} else {
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) {
this._error = e?.message ? String(e.message) : String(e);
@@ -323,7 +347,11 @@ class BahmcloudStorePanel extends HTMLElement {
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;
this._restoring = true;
@@ -336,7 +364,9 @@ class BahmcloudStorePanel extends HTMLElement {
this._restoreError = this._safeText(resp?.message) || "Restore failed.";
} else {
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();
}
} catch (e) {
@@ -442,6 +472,10 @@ class BahmcloudStorePanel extends HTMLElement {
this._readmeError = null;
this._readmeExpanded = false;
this._readmeCanToggle = false;
this._releaseNotesLoading = false;
this._releaseNotesText = null;
this._releaseNotesHtml = null;
this._releaseNotesError = null;
// Versions dropdown
if (!(repoId in this._selectedVersionByRepoId)) {
@@ -452,6 +486,7 @@ class BahmcloudStorePanel extends HTMLElement {
this._loadRepoDetails(repoId);
this._loadReadme(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) {
if (!this._hass) return;
this._readmeLoading = true;
@@ -922,6 +992,58 @@ class BahmcloudStorePanel extends HTMLElement {
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() {
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 installedVersion = this._safeText(r?.installed_version);
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 === "not_installed" && installed) return false;
if (this._filter === "updates" && !updateAvailable) return false;
if (this._filter === "custom" && r?.source !== "custom") return false;
if (this._filter === "favorites" && !favorite) return false;
return true;
})
@@ -962,16 +1086,22 @@ class BahmcloudStorePanel extends HTMLElement {
const ainstalled = this._asBoolStrict(a?.installed);
const ainstalledVersion = this._safeText(a?.installed_version);
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 binstalled = this._asBoolStrict(b?.installed);
const binstalledVersion = this._safeText(b?.installed_version);
const bupdate = binstalled && !!blatest && (!binstalledVersion || blatest !== binstalledVersion);
const bfavorite = this._asBoolStrict(b?.favorite) || this._isFavoriteRepo(b?.id);
if (this._sort === "updates_first") {
if (aupdate !== bupdate) return aupdate ? -1 : 1;
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 (ainstalled !== binstalled) return ainstalled ? -1 : 1;
return an.localeCompare(bn);
@@ -994,6 +1124,7 @@ class BahmcloudStorePanel extends HTMLElement {
const installed = this._asBoolStrict(r?.installed);
const installedVersion = this._safeText(r?.installed_version);
const updateAvailable = installed && !!latest && (!installedVersion || latest !== installedVersion);
const favorite = this._asBoolStrict(r?.favorite) || this._isFavoriteRepo(r?.id);
const badges = [];
// Source badges
@@ -1001,6 +1132,7 @@ class BahmcloudStorePanel extends HTMLElement {
else if (r?.source === "hacs") badges.push("HACS");
else if (r?.source === "custom") badges.push("Custom");
if (favorite) badges.push("Pinned");
if (installed) badges.push("Installed");
if (updateAvailable) badges.push("Update");
@@ -1044,6 +1176,7 @@ class BahmcloudStorePanel extends HTMLElement {
</select>
<select id="filter">
<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="not_installed" ${this._filter === "not_installed" ? "selected" : ""}>Not installed</option>
<option value="updates" ${this._filter === "updates" ? "selected" : ""}>Updates available</option>
@@ -1051,6 +1184,7 @@ class BahmcloudStorePanel extends HTMLElement {
</select>
<select id="sort">
<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="installed_first" ${this._sort === "installed_first" ? "selected" : ""}>Installed first</option>
</select>
@@ -1173,6 +1307,7 @@ class BahmcloudStorePanel extends HTMLElement {
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?.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_maintainer) ? `Maintainer: ${this._safeText(r?.meta_maintainer)}` : 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 installedVersion = this._safeText(r?.installed_version);
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 favorite = this._asBoolStrict(r?.favorite) || this._isFavoriteRepo(repoId);
const installType = this._safeText(r?.install_type) || "integration";
const busyInstall = this._installingRepoId === repoId;
const busyUpdate = this._updatingRepoId === repoId;
@@ -1250,10 +1388,36 @@ class BahmcloudStorePanel extends HTMLElement {
</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 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 restoreBtn = `<button class="primary" id="btnRestore" ${!installed || busy ? "disabled" : ""}>Restore</button>`;
const favoriteBtn = `<button id="btnFavorite">${favorite ? "Unpin" : "Pin"}</button>`;
const restartHint = this._restartRequired
? `
@@ -1300,11 +1464,14 @@ class BahmcloudStorePanel extends HTMLElement {
<div><strong>Installed version:</strong> ${this._esc(installedVersion || "-")}</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>Installed paths:</strong> ${installedPaths.length ? this._esc(installedPaths.join(", ")) : "-"}</div>
</div>
${versionSelect}
${releaseNotesBlock}
<div class="row" style="margin-top:14px; gap:10px; flex-wrap:wrap;">
${favoriteBtn}
${installBtn}
${updateBtn}
${uninstallBtn}
@@ -1329,6 +1496,13 @@ class BahmcloudStorePanel extends HTMLElement {
const btnRestart = root.getElementById("btnRestart");
const btnReadmeToggle = root.getElementById("btnReadmeToggle");
const selVersion = root.getElementById("selVersion");
const btnFavorite = root.getElementById("btnFavorite");
if (btnFavorite) {
btnFavorite.addEventListener("click", () => {
if (this._detailRepoId) this._toggleFavorite(this._detailRepoId);
});
}
if (btnInstall) {
btnInstall.addEventListener("click", () => {
@@ -1342,6 +1516,7 @@ class BahmcloudStorePanel extends HTMLElement {
if (!this._detailRepoId) return;
const v = selVersion.value != null ? String(selVersion.value) : "";
this._selectedVersionByRepoId[this._detailRepoId] = v;
this._loadReleaseNotes(this._detailRepoId);
});
}
@@ -1380,7 +1555,22 @@ class BahmcloudStorePanel extends HTMLElement {
}
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._readmeHtml) {
@@ -1392,6 +1582,20 @@ class BahmcloudStorePanel extends HTMLElement {
} else {
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() {
@@ -1459,12 +1663,16 @@ class BahmcloudStorePanel extends HTMLElement {
})
.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
? "Loading backups…"
: this._restoreError
? this._safeText(this._restoreError)
: opts.length
? "Select a backup to restore. This will overwrite files under /config/custom_components and requires a restart."
? actionHint
: "No backups found.";
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
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)
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)
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
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)
if tag:
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:
return tag, src
@@ -316,7 +320,16 @@ async def _gitlab_latest_version(
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)
owner, repo = _split_owner_repo(repo_url)
@@ -337,7 +350,7 @@ async def fetch_repo_info(hass: HomeAssistant, repo_url: str) -> RepoInfo:
try:
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)
if isinstance(data, dict):
@@ -356,7 +369,7 @@ async def fetch_repo_info(hass: HomeAssistant, repo_url: str) -> RepoInfo:
if 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_source = src
return info
@@ -514,6 +527,7 @@ async def fetch_repo_versions(
provider: str | None = None,
default_branch: str | None = None,
limit: int = 20,
github_token: str | None = None,
) -> list[dict[str, str]]:
"""List available versions/refs for a repository.
@@ -523,7 +537,7 @@ async def fetch_repo_versions(
- source: release|tag|branch
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.
"""
@@ -541,6 +555,8 @@ async def fetch_repo_versions(
session = async_get_clientsession(hass)
headers = {"User-Agent": UA}
if prov == "github":
headers = _github_headers(github_token)
out: list[dict[str, str]] = []
seen: set[str] = set()
@@ -559,11 +575,13 @@ async def fetch_repo_versions(
try:
if prov == "github":
# Releases
gh_headers = {"Accept": "application/vnd.github+json", "User-Agent": UA}
# Releases (prefer these over tags)
# 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(
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,
)
if isinstance(data, list):
@@ -581,7 +599,7 @@ async def fetch_repo_versions(
# Tags
data, _ = await _safe_json(
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,
)
if isinstance(data, list):
@@ -660,4 +678,78 @@ async def fetch_repo_versions(
except Exception:
_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
domains: list[str]
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_manifest_version: str | None = None # informational only
ref: str | None = None # kept for backward compatibility / diagnostics
@@ -197,6 +199,11 @@ class BCSStorage:
if not isinstance(domains, list):
domains = []
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")
ref = entry.get("ref")
@@ -213,6 +220,8 @@ class BCSStorage:
url=str(entry.get("url") or ""),
domains=domains,
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_manifest_version=str(installed_manifest_version) if installed_manifest_version else None,
ref=str(ref) if ref else None,
@@ -238,9 +247,11 @@ class BCSStorage:
repo_id: str,
url: str,
domains: list[str],
installed_version: str | None,
installed_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:
data = await self._load()
installed = data.get("installed_repos", {})
@@ -252,6 +263,8 @@ class BCSStorage:
"repo_id": str(repo_id),
"url": str(url),
"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()),
# IMPORTANT: this is what BCS uses as "installed version" (ref/tag/branch)
"installed_version": installed_version,
@@ -268,4 +281,4 @@ class BCSStorage:
if isinstance(installed, dict) and repo_id in installed:
installed.pop(repo_id, None)
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": {
"restart_required": {
"title": "Restart required",
@@ -15,4 +40,4 @@
}
}
}
}
}

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import inspect
import logging
from dataclasses import dataclass
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.entity_platform import AddEntitiesCallback
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__)
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:
"""Return a human-friendly name for a repo update entity."""
try:
@@ -117,8 +129,36 @@ class BCSRepoUpdateEntity(UpdateEntity):
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."""
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] = []
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():
if not isinstance(data, dict):
continue
@@ -146,7 +186,7 @@ async def async_setup_platform(
discovery_info=None,
):
"""Set up BCS update entities."""
core: BCSCore | None = hass.data.get(DOMAIN)
core: BCSCore | None = _get_core(hass)
if not core:
_LOGGER.debug("BCS core not available, skipping update platform setup")
return
@@ -159,4 +199,13 @@ async def async_setup_platform(
def _handle_update() -> None:
_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] = {}
if "hacs_enabled" in data:
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:
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)
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):
url = "/api/bcs/install"
name = "api:bcs_install"
@@ -502,6 +540,9 @@ class BCSRepoDetailView(HomeAssistantView):
domains = inst.get("domains") or []
if not isinstance(domains, list):
domains = []
installed_paths = inst.get("installed_paths") or []
if not isinstance(installed_paths, list):
installed_paths = []
return web.json_response({
"ok": True,
@@ -518,13 +559,18 @@ class BCSRepoDetailView(HomeAssistantView):
"latest_version": repo.latest_version,
"latest_version_source": repo.latest_version_source,
"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_maintainer": repo.meta_maintainer,
"meta_source": repo.meta_source,
"installed": installed,
"install_type": inst.get("install_type") or self.core._repo_install_type(repo),
"installed_version": inst.get("installed_version"),
"installed_manifest_version": inst.get("installed_manifest_version"),
"installed_domains": domains,
"installed_paths": installed_paths,
"favorite": self.core.is_favorite_repo(repo.id),
}
}, status=200)
except Exception as e: