38 Commits
0.6.7 ... 0.7.3

Author SHA1 Message Date
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
357049aa7b add 0.6.8 2026-01-19 14:35:56 +00:00
d85ef5621c 0.6.8 2026-01-19 14:35:24 +00:00
faac180212 0.6.8 2026-01-19 14:35:05 +00:00
76d8a45f37 README_FULL.md aktualisiert 2026-01-19 09:00:21 +00:00
d68d322df6 README_DEVELOPER.md aktualisiert 2026-01-19 08:27:01 +00:00
113c951028 Full 2026-01-19 08:09:48 +00:00
2a4ab676ec Ne 2026-01-19 08:06:42 +00:00
c0a04f505e README.md aktualisiert 2026-01-19 07:53:33 +00:00
a63006bb07 Add full 2026-01-19 07:42:35 +00:00
f8e678215d Add Developer 2026-01-19 07:41:31 +00:00
f745f8ec1e Add readme 2026-01-19 07:39:35 +00:00
20 changed files with 1286 additions and 56 deletions

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>

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

@@ -0,0 +1,41 @@
# 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.
### 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>

119
.idea/start prompt.md generated Normal file
View File

@@ -0,0 +1,119 @@
## Bahmcloud Store Start Prompt
You are working in the `Bahmcloud Store` repository.
Project identity:
- This is a Home Assistant custom integration named `bahmcloud_store`.
- The product goal is a provider-neutral store for Home Assistant custom integrations, similar in spirit to HACS, but not limited to GitHub.
- Current real provider implementation is strongest for GitHub, GitLab, and Gitea-compatible providers. Unknown providers currently fall through the Gitea-style code paths, so do not assume every arbitrary Git provider works without verification.
Working rules:
- Never push, commit, tag, or create a release without explicit user approval.
- Always document every change in `.idea/changes.md` with the current date and a detailed description of what changed, why it changed, and any verification done.
- If a new release is created, update `CHANGELOG.md` with all relevant changes since the last released version.
- Prefer changing the real active code paths, not legacy or duplicate files.
- When docs and code disagree, trust the current code first, then update docs to match verified behavior.
- Do not remove user changes or perform destructive git actions unless the user explicitly asks for them.
Repository facts to keep in mind:
- Main integration path: `custom_components/bahmcloud_store/`
- Entry point: `__init__.py`
- Main runtime/service layer: `core.py`
- HTTP API layer: `views.py`
- Provider detection and remote version/readme fetching: `providers.py`
- Repo metadata loading (`bcs.yaml`, `hacs.yaml`, `hacs.json`): `metadata.py`
- Persistent storage: `storage.py`
- Update entities: `update.py`
- Repairs restart flow: `repairs.py`
- Frontend panel actually loaded by Home Assistant: `panel/panel.js`
- Panel registration uses `/api/bahmcloud_store_static/panel.js?v=109`; if frontend assets change in a real release, bump the version query to break HA browser cache.
Current architecture summary:
- Setup is config-entry only. YAML configuration is intentionally unsupported and only logs a warning if present.
- Only one integration instance is allowed.
- The official store index URL is fixed in `const.py`:
`https://git.bahmcloud.de/bahmcloud/ha_store/raw/branch/main/store.yaml`
- The store index format currently used by the active code is a YAML mapping with:
- `refresh_seconds`
- `repos`
- each repo entry contains at least `url`, optionally `name` and `category`
- Refresh flow:
- integration initializes storage and caches
- after `homeassistant_started`, a delayed refresh runs
- periodic refresh also runs on an interval from the store index
- refresh merges official index repos, optional HACS repos, and user custom repos
- cached enrichment is applied first, installed repos are refreshed eagerly, and the rest are enriched in the background
- Optional HACS support exists behind the `hacs_enabled` setting and currently loads the official HACS integration list from `data-v2.hacs.xyz`. That path is GitHub-only metadata, not a general provider abstraction.
- Install/update flow:
- picks a ref from selected version, latest version, or default branch
- downloads a ZIP from the provider-specific archive endpoint
- extracts the repository
- finds `custom_components`
- installs every integration folder inside `custom_components/*` that contains `manifest.json`
- stores the installed ref and manifest version in HA storage
- creates backups before overwriting existing domains
- marks restart required through Repairs
- Backup/restore behavior:
- backups live under `/config/.bcs_backups/<domain>/<timestamp>/`
- restore updates stored installed-version info so the UI and update entities reflect the restored state
- retention is currently 5 backups per domain
- Installed-state reconciliation exists:
- if folders are deleted from `/config/custom_components`, stale installed entries are removed from storage
- BCS also tries to self-reconcile as installed when it was deployed externally
Public/API contract to preserve unless intentionally changed:
- `GET /api/bcs`
- `POST /api/bcs?action=refresh`
- `GET /api/bcs/settings`
- `POST /api/bcs/settings`
- `GET /api/bcs/readme?repo_id=...`
- `GET /api/bcs/versions?repo_id=...`
- `GET /api/bcs/repo?repo_id=...`
- `POST /api/bcs/install?repo_id=...&version=...`
- `POST /api/bcs/update?repo_id=...&version=...`
- `POST /api/bcs/uninstall?repo_id=...`
- `GET /api/bcs/backups?repo_id=...`
- `POST /api/bcs/restore?repo_id=...&backup_id=...`
- `POST /api/bcs/restart`
- `DELETE /api/bcs/custom_repo?id=...`
Storage model:
- Home Assistant storage key: `bcs_store`
- Stored sections:
- `custom_repos`
- `installed_repos`
- `settings`
- `hacs_cache`
- `repo_cache`
Frontend/UI facts:
- The active HA panel script is `custom_components/bahmcloud_store/panel/panel.js`.
- The richer UI is implemented there: source filtering, HACS toggle, repo detail loading, version selection, backups restore modal, restart action, and history handling.
- `panel/app.js`, `panel/index.html`, and `panel/styles.css` look like older standalone or legacy panel assets. Treat them as secondary unless you confirm they are still used for a real path.
Code-analysis findings that should influence future work:
- `store.py` represents an older store implementation with a different data model (`packages`, `source_path`, older API routes). It does not appear to be the active runtime path for the current integration flow.
- `custom_repo_view.py` duplicates logic that already exists in `views.py` and looks legacy/unreferenced.
- README files describe the project direction correctly at a high level, but some wording overstates provider generality. The actual code is provider-neutral in intent, but concretely implemented around GitHub, GitLab, and Gitea-style endpoints.
- The end-user and full READMEs contain some stale or inconsistent details compared with the current UI and code. Verify behavior in source before using README text as specification.
- There are visible encoding/mojibake issues in some documentation and older UI assets. Preserve valid UTF-8 when editing.
Project constraints to respect in future edits:
- Keep async I/O non-blocking in Home Assistant.
- Avoid startup-heavy network work before HA is fully started.
- Preserve repo-cache and HACS-cache behavior unless intentionally redesigning refresh logic.
- Preserve backup-before-overwrite safety for install/update/restore flows.
- Preserve update-entity behavior for installed repos.
- Keep the integration UI-admin-only and config-entry-based.
Recommended workflow for future tasks:
1. Read `README.md`, `README_DEVELOPER.md`, and relevant source files.
2. Verify whether the requested change belongs in active code or in legacy files.
3. Implement the change in the active runtime path.
4. Update documentation if behavior changed.
5. Append a detailed dated entry to `.idea/changes.md`.
6. If and only if a release is being prepared with user approval, fold release-worthy changes into `CHANGELOG.md`.
Current release baseline:
- `manifest.json` version is `0.7.3`
- Latest documented release in `CHANGELOG.md` is `0.7.3` dated `2026-03-23`

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,81 @@ Sections:
---
## 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
- Fixed excessive GitHub API requests causing rate limiting
- Fixed missing persistence of repository versions and metadata
- Fixed background enrichment re-running after Home Assistant restart
### Changed
- Repository metadata (versions, README, descriptions) is now fetched once in the background
- Cached data is reused and only refreshed on explicit user interaction
- Improved separation between startup refresh and on-demand updates
### Improved
- Significantly reduced Home Assistant startup time
- Greatly reduced GitHub API usage
- More reliable version selection for GitHub and HACS repositories
### Notes
- Background tasks may still appear in DEBUG logs (expected behavior)
- No functional UI changes in this release
## [0.6.7] - 2026-01-19
### Fixed

View File

@@ -1,3 +1,50 @@
# bahmcloud_store
# Bahmcloud Store (BCS) for Home Assistant
Bahmcloud Store for installing costum_components to Homeassistant
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.
This README is intended for **end users**.
---
## Installation
### Option A: Home Assistant OS / Supervised (Add-on recommended)
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
---
### Option B: Manual Installation (Container / Core)
1. Download the latest release
2. Copy `custom_components/bahmcloud_store` to:
/config/custom_components/bahmcloud_store
3. Restart Home Assistant
---
## Repository Sources
- **BCS Official**
- **HACS**
- **Custom**
---
## 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

115
README_DEVELOPER.md Normal file
View File

@@ -0,0 +1,115 @@
# Bahmcloud Store Developer Documentation
For contributors and maintainers.
## Architecture
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```
### Integration Layout
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
## 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
## Metadata
- Prefer `bcs.yaml`, fallback `hacs.json` / `hacs.yaml`
- Populate name/description/category/author/maintainer
## HTTP API (excerpt)
Base: /api/bcs
- 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)
```
3) (Recommended) Add `bcs.yaml` to your repo:
```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
```
4) Open PR; validation checks: reachable, has `custom_components/<domain>/manifest.json`, sensible metadata
5) Merge → appears in **BCS Official** after refresh
## Coding Guidelines
- Async I/O, no blocking event loop
- Respect provider rate limits
- Clean logging around refresh/install/update/restore
- Keep UI responsive; throttle updates
---
## Planed Features
- 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"
-

217
README_FULL.md Normal file
View File

@@ -0,0 +1,217 @@
# 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.
> BCS lets you install & manage custom integrations from **GitHub/GitLab/Gitea** and your own sources, with backups, restore, and version pinning.
---
## Contents
- Concepts
- Sources (BCS / HACS / Custom)
- UI Overview
- Finding Integrations
- Installing
- Selecting Versions / Downgrading
- Updating
- Uninstalling
- Backups & Restore
- Custom Repositories
- HACS Repositories
- Update Entities in Home Assistant
- Background Caching & Performance
- Restart Required
- Troubleshooting
- FAQ
---
## 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 / HACS / Custom)
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
You can **filter by source** with the **Source** dropdown (All / BCS Official / HACS / Custom).
---
## 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)
Repository card:
- Name, description, badges (source, installed/update), category
- Buttons: **Install / Update / Uninstall**
- **Readme** expandable
- **Open** to see details (available versions, metadata)
---
## Finding Integrations
1. Use **Search** to filter by keywords.
2. Combine with **Category** and **Source**.
3. Sort to surface desired results.
Descriptions and latest versions are filled progressively by a background process; opening a repo loads details on demand.
---
## 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)
**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.
---
## Selecting Versions / Downgrading
- 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**.
---
## 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).
**Tip:** Opening a repository detail view forces an immediate check for the latest version for that repo.
---
## 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.
---
## Backups & Restore
Before an update/install over existing files, BCS creates a backup:
```
/config/.bcs_backups/<domain>/<timestamp>/
```
**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.
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.
---
## 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)
**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
**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/*```))
Custom repos get the **Custom** badge and can be filtered via **Source**.
---
## HACS Repositories
Enable the **HACS official** toggle to include official HACS integrations.
- 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).
With many HACS repos, metadata loads in the background; names/descriptions appear progressively and are cached.
---
## Update Entities in Home Assistant
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)
---
## Background Caching & Performance
- **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
---
## Restart Required
After install, update, or restore, BCS raises a **Restart required** item in Home Assistant (Repairs). You can restart directly from there.
---
## 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.
---
## 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.

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=110",
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))
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__)
@@ -42,6 +49,9 @@ HACS_CACHE_TTL_SECONDS = 60 * 60 * 24 # 24h
# - persists across restarts
# - keeps UI populated (name/description/latest) without blocking startup
REPO_CACHE_TTL_SECONDS = 6 * 60 * 60 # 6h
# Release/tag lists change rarely and can be expensive for some providers (e.g. GitHub rate limits).
# Cache them longer and refresh only on-demand (when the user opens the version selector).
VERSIONS_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60 # 7d
class BCSError(Exception):
@@ -55,6 +65,7 @@ class BCSInstallError(BCSError):
@dataclass
class BCSConfig:
store_url: str
github_token: str | None = None
@dataclass
@@ -177,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)
@@ -320,6 +433,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
@@ -572,6 +691,12 @@ class BCSCore:
if isinstance(cached, dict):
ts = int(cached.get("ts") or 0)
# Background enrichment should be a one-time best-effort pass.
# If we already attempted it once for this repo, do not keep retrying on every refresh.
# On-demand (opening repo details / version selector) can still refresh and persist.
if bool(cached.get("bg_done")):
return False
# Missing key fields -> enrich.
if not r.latest_version:
return True
@@ -634,10 +759,20 @@ class BCSCore:
await self._enrich_one_repo(r)
except Exception:
_LOGGER.debug("BCS background enrich failed for %s", repo_id, exc_info=True)
# Mark as attempted so we don't keep hammering the provider.
async with self._repo_cache_lock:
cached = self._repo_cache.setdefault(str(repo_id), {})
if isinstance(cached, dict):
cached["bg_done"] = True
cached["bg_done_ts"] = int(time.time())
return
async with self._repo_cache_lock:
self._update_repo_cache_from_repo(repo_id, r)
cached = self._repo_cache.setdefault(str(repo_id), {})
if isinstance(cached, dict):
cached["bg_done"] = True
cached["bg_done_ts"] = int(time.time())
updated += 1
# Process sequentially but allow limited concurrency.
@@ -677,7 +812,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
@@ -736,7 +871,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
@@ -788,11 +923,71 @@ 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 []
return await fetch_repo_versions(self.hass, repo.url)
# Prefer cached version lists to avoid hammering provider APIs (notably GitHub unauthenticated
# 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:
cached = self._repo_cache.get(str(repo_id)) if isinstance(self._repo_cache, dict) else None
if isinstance(cached, dict):
cached_ts = int(cached.get("versions_ts", 0) or 0)
now = int(time.time())
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,
provider=repo.provider,
default_branch=repo.default_branch,
limit=limit,
github_token=self.config.github_token,
)
except Exception:
versions = []
# If the provider fetch returned only the basic fallbacks ("Latest" + "Branch") but we have
# a previously cached richer list, keep using the cached list.
if (
isinstance(cached, dict)
and cached.get("versions")
and len(list(cached.get("versions") or [])) > 2
and len(versions) <= 2
):
return list(cached.get("versions") or [])
# Persist whatever we got (even if small) to avoid repeated calls when rate-limited.
async with self._repo_cache_lock:
entry = self._repo_cache.setdefault(str(repo_id), {}) if isinstance(self._repo_cache, dict) else {}
if isinstance(entry, dict):
entry["versions"] = versions
entry["versions_ts"] = now
await self._persist_repo_cache()
return versions
def _add_cache_buster(self, url: str) -> str:
parts = urlsplit(url)
@@ -972,21 +1167,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:

View File

@@ -1,8 +1,9 @@
{
"domain": "bahmcloud_store",
"name": "Bahmcloud Store",
"version": "0.6.7",
"version": "0.7.3",
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
"config_flow": true,
"platforms": ["update"],
"requirements": [],
"codeowners": ["@bahmcloud"],

View File

@@ -56,6 +56,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;
@@ -442,6 +446,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 +460,7 @@ class BahmcloudStorePanel extends HTMLElement {
this._loadRepoDetails(repoId);
this._loadReadme(repoId);
this._loadVersions(repoId);
this._loadReleaseNotes(repoId);
}
@@ -499,6 +508,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;
@@ -1250,6 +1294,31 @@ 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>`;
@@ -1303,6 +1372,7 @@ class BahmcloudStorePanel extends HTMLElement {
</div>
${versionSelect}
${releaseNotesBlock}
<div class="row" style="margin-top:14px; gap:10px; flex-wrap:wrap;">
${installBtn}
@@ -1342,6 +1412,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 +1451,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 +1478,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() {

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):
@@ -661,3 +679,77 @@ async def fetch_repo_versions(
except Exception:
_LOGGER.debug("fetch_repo_versions failed for %s", repo_url, exc_info=True)
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

@@ -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",

View File

@@ -10,11 +10,21 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity import EntityCategory
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:
@@ -146,7 +156,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
@@ -160,3 +170,12 @@ async def async_setup_platform(
_sync_entities(core, entities, async_add_entities)
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

@@ -349,6 +349,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"