Compare commits
189 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 313fcf94e7 | |||
| d976ab56e3 | |||
| 80c1c2966f | |||
| de3fbf1a12 | |||
| a55281938c | |||
| 48f8ef6265 | |||
| 9448176ff4 | |||
| a029738ec8 | |||
| 754540d578 | |||
| 1103c5e326 | |||
| b7d4d0ded4 | |||
| a8ff892993 | |||
| 90223e3fc4 | |||
| 0f5504b67d | |||
| 5fff1b2692 | |||
| c8356c7603 | |||
| 0c49a50fc9 | |||
| fa48841645 | |||
| 1445fff739 | |||
| 5cf365f354 | |||
| f73ce4095c | |||
| 1484d53f8c | |||
| 0e99c9c59e | |||
| 644e61aab0 | |||
| 4c2a104af7 | |||
| 95dd8b9dc2 | |||
| 8b01c04a4c | |||
| 981f56a693 | |||
| f6bd04f354 | |||
| 8da8a26a90 | |||
| 42fe5afe52 | |||
| 437c020566 | |||
| b863ed4d51 | |||
| 368642345d | |||
| 43bc31c8b4 | |||
| 357049aa7b | |||
| d85ef5621c | |||
| faac180212 | |||
| 76d8a45f37 | |||
| d68d322df6 | |||
| 113c951028 | |||
| 2a4ab676ec | |||
| c0a04f505e | |||
| a63006bb07 | |||
| f8e678215d | |||
| f745f8ec1e | |||
| 33502a6d99 | |||
| 1306ee9518 | |||
| e37546cab1 | |||
| 88c3233fd1 | |||
| 02f3047080 | |||
| d4012589e6 | |||
| 8ac67fa60c | |||
| 981490c152 | |||
| 99b2a0f0c5 | |||
| 7ead494765 | |||
| 342b6f6c57 | |||
| 66ca63b2be | |||
| e8325f722f | |||
| 7c1a91937a | |||
| 7ac3289bb7 | |||
| 19bdbd1b9a | |||
| 24363cd2ac | |||
| e19ca5bff1 | |||
| 05897d4370 | |||
| 7a3a28d87f | |||
| 240cded8a9 | |||
| 31e241f052 | |||
| de579682a0 | |||
| 9acbd5046c | |||
| 8d63c88e69 | |||
| cffb0af60e | |||
| 857b7a127a | |||
| 66b24ece48 | |||
| 0cc3b466e0 | |||
| f1e03b31a1 | |||
| 4e12d596d6 | |||
| fa97f89afb | |||
| 0718bee185 | |||
| 1a53107450 | |||
| ab82cc6fd3 | |||
| 8e51f144e1 | |||
| f292e22301 | |||
| 2eb194c001 | |||
| f4e367987a | |||
| 08aa4b5e15 | |||
| b1676482f0 | |||
| e46cd6e488 | |||
| edd2fdd3fb | |||
| a4a0c1462b | |||
| 196e63c08e | |||
| 518ac1d59d | |||
| ad699dc69a | |||
| a8e247d288 | |||
| 318d517575 | |||
| db137be5b1 | |||
| 83cec0f75a | |||
| cda9914d50 | |||
| 3acefbfbe8 | |||
| 4d10c5c91e | |||
| 810ff6fe85 | |||
| b2d3d940f2 | |||
| 8b1d828c59 | |||
| 824a9e5cad | |||
| 1cbc204e88 | |||
| 561c323e67 | |||
| 5c604b40c6 | |||
| cc8db6a034 | |||
| e0ad133221 | |||
| 0e27a03aaf | |||
| e2dfa20789 | |||
| 8e8b58d2d2 | |||
| 76ecaabd98 | |||
| 3f14dc3bd9 | |||
| 50a78714cc | |||
| 3bf01c91f1 | |||
| 7aa14284dd | |||
| 24933e980d | |||
| e10624df6b | |||
| 1a1ebd3821 | |||
| d3d61067db | |||
| 23b605becf | |||
| c07f8615e4 | |||
| 9b209a15bf | |||
| 30258bd2c0 | |||
| 2c8ca490ea | |||
| 9e8a8e81b9 | |||
| f5b2534fdb | |||
| 8b3916c3fa | |||
| 13e71046f8 | |||
| 58e3674325 | |||
| 828d84caa3 | |||
| c18e93406a | |||
| 9af18ba090 | |||
| fff50a1580 | |||
| f8e9967c3a | |||
| 7bc493eb45 | |||
| b97b970a45 | |||
| 593e0c367d | |||
| 8e0817a64b | |||
| dfc7e44565 | |||
| c9c4f99fbf | |||
| 37cc11c9ee | |||
| 9c773c07e8 | |||
| c04612e159 | |||
| 5796012189 | |||
| 01576153d8 | |||
| 30484a08c1 | |||
| faf122aa1c | |||
| 1e86df49e9 | |||
| df631eec9e | |||
| 07240d1268 | |||
| 50587ffbbd | |||
| d6347e7e59 | |||
| 870e77ec13 | |||
| 38fb9fb073 | |||
| c20bd4dd07 | |||
| 296c816633 | |||
| 18a2b5529c | |||
| 246fab7e1e | |||
| ce5802721f | |||
| 2f46966fe2 | |||
| 132f9e27c1 | |||
| 618511be73 | |||
| 6488b434d8 | |||
| bffc594da5 | |||
| d78217100c | |||
| 09e1ef1af5 | |||
| 9ad558c9ab | |||
| 19df0eea22 | |||
| 745979b9a6 | |||
| f861b2490a | |||
| 32946c1a98 | |||
| a9a681d801 | |||
| 2ae6ac43a5 | |||
| 504c126c2c | |||
| 85cc97b557 | |||
| 4ca80a9c88 | |||
| ac5bc8a6f4 | |||
| c4361cc8bd | |||
| 1794d579d2 | |||
| bcfbf7151c | |||
| 38730cdd31 | |||
| 5d5d78d727 | |||
| 67297bfc9c | |||
| 82fda5dfc4 | |||
| 907f14b73c | |||
| 3eefd447ac | |||
| 72ce95525c |
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal 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
8
.idea/Projects.iml
generated
Normal 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>
|
||||
56
.idea/changes.md
generated
Normal file
56
.idea/changes.md
generated
Normal file
@@ -0,0 +1,56 @@
|
||||
# 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.
|
||||
|
||||
### 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
8
.idea/modules.xml
generated
Normal 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>
|
||||
134
.idea/start prompt.md
generated
Normal file
134
.idea/start prompt.md
generated
Normal file
@@ -0,0 +1,134 @@
|
||||
## Bahmcloud Store Start Prompt
|
||||
|
||||
You are working in the `Bahmcloud Store` repository.
|
||||
|
||||
Project identity:
|
||||
- This is a Home Assistant custom integration named `bahmcloud_store`.
|
||||
- The product goal is a provider-neutral store for Home Assistant custom integrations, similar in spirit to HACS, but not limited to GitHub.
|
||||
- Current real provider implementation is strongest for GitHub, GitLab, and Gitea-compatible providers. Unknown providers currently fall through the Gitea-style code paths, so do not assume every arbitrary Git provider works without verification.
|
||||
- The long-term product direction is broader than integrations only: the store should evolve into a Home Assistant content store that can manage integrations, blueprints, templates, Lovelace designs, and additional future categories from multiple git providers.
|
||||
|
||||
Working rules:
|
||||
- All project-facing work must be done in English only, regardless of the language the user speaks in chat. This applies to code comments, documentation, changelog entries, release notes, commit messages, PR text, UI text, issue text, and any other project artifacts unless the user explicitly requests a specific exception for repository content.
|
||||
- Never push, commit, tag, or create a release without explicit user approval.
|
||||
- Always document every change in `.idea/changes.md` with the current date and a detailed description of what changed, why it changed, and any verification done.
|
||||
- If a new release is created, update `CHANGELOG.md` with all relevant changes since the last released version.
|
||||
- Never mention prompts, AI tools, KI tools, or files inside the `.idea` folder in commit messages, push descriptions, release notes, or changelog entries.
|
||||
- Use plain version numbers for future git tags and release names, without a leading `v` prefix. Example: `0.7.4`, not `v0.7.4`.
|
||||
- Never mention internal workflow rules or repository-internal prompt/process decisions in any public-facing project communication. This includes commit messages, push descriptions, git tags, release names, release notes, changelog entries, and similar outward-facing texts.
|
||||
- Prefer changing the real active code paths, not legacy or duplicate files.
|
||||
- When docs and code disagree, trust the current code first, then update docs to match verified behavior.
|
||||
- Do not remove user changes or perform destructive git actions unless the user explicitly asks for them.
|
||||
|
||||
Repository facts to keep in mind:
|
||||
- Main integration path: `custom_components/bahmcloud_store/`
|
||||
- Entry point: `__init__.py`
|
||||
- Main runtime/service layer: `core.py`
|
||||
- HTTP API layer: `views.py`
|
||||
- Provider detection and remote version/readme fetching: `providers.py`
|
||||
- Repo metadata loading (`bcs.yaml`, `hacs.yaml`, `hacs.json`): `metadata.py`
|
||||
- Persistent storage: `storage.py`
|
||||
- Update entities: `update.py`
|
||||
- Repairs restart flow: `repairs.py`
|
||||
- Frontend panel actually loaded by Home Assistant: `panel/panel.js`
|
||||
- Panel registration uses `/api/bahmcloud_store_static/panel.js?v=109`; if frontend assets change in a real release, bump the version query to break HA browser cache.
|
||||
|
||||
Current architecture summary:
|
||||
- Setup is config-entry only. YAML configuration is intentionally unsupported and only logs a warning if present.
|
||||
- Only one integration instance is allowed.
|
||||
- The official store index URL is fixed in `const.py`:
|
||||
`https://git.bahmcloud.de/bahmcloud/ha_store/raw/branch/main/store.yaml`
|
||||
- The store index format currently used by the active code is a YAML mapping with:
|
||||
- `refresh_seconds`
|
||||
- `repos`
|
||||
- each repo entry contains at least `url`, optionally `name` and `category`
|
||||
- Refresh flow:
|
||||
- integration initializes storage and caches
|
||||
- after `homeassistant_started`, a delayed refresh runs
|
||||
- periodic refresh also runs on an interval from the store index
|
||||
- refresh merges official index repos, optional HACS repos, and user custom repos
|
||||
- cached enrichment is applied first, installed repos are refreshed eagerly, and the rest are enriched in the background
|
||||
- Optional HACS support exists behind the `hacs_enabled` setting and currently loads the official HACS integration list from `data-v2.hacs.xyz`. That path is GitHub-only metadata, not a general provider abstraction.
|
||||
- Install/update flow:
|
||||
- picks a ref from selected version, latest version, or default branch
|
||||
- downloads a ZIP from the provider-specific archive endpoint
|
||||
- extracts the repository
|
||||
- finds `custom_components`
|
||||
- installs every integration folder inside `custom_components/*` that contains `manifest.json`
|
||||
- stores the installed ref and manifest version in HA storage
|
||||
- creates backups before overwriting existing domains
|
||||
- marks restart required through Repairs
|
||||
- Backup/restore behavior:
|
||||
- backups live under `/config/.bcs_backups/<domain>/<timestamp>/`
|
||||
- restore updates stored installed-version info so the UI and update entities reflect the restored state
|
||||
- retention is currently 5 backups per domain
|
||||
- Installed-state reconciliation exists:
|
||||
- if folders are deleted from `/config/custom_components`, stale installed entries are removed from storage
|
||||
- BCS also tries to self-reconcile as installed when it was deployed externally
|
||||
|
||||
Public/API contract to preserve unless intentionally changed:
|
||||
- `GET /api/bcs`
|
||||
- `POST /api/bcs?action=refresh`
|
||||
- `GET /api/bcs/settings`
|
||||
- `POST /api/bcs/settings`
|
||||
- `GET /api/bcs/readme?repo_id=...`
|
||||
- `GET /api/bcs/versions?repo_id=...`
|
||||
- `GET /api/bcs/repo?repo_id=...`
|
||||
- `POST /api/bcs/install?repo_id=...&version=...`
|
||||
- `POST /api/bcs/update?repo_id=...&version=...`
|
||||
- `POST /api/bcs/uninstall?repo_id=...`
|
||||
- `GET /api/bcs/backups?repo_id=...`
|
||||
- `POST /api/bcs/restore?repo_id=...&backup_id=...`
|
||||
- `POST /api/bcs/restart`
|
||||
- `DELETE /api/bcs/custom_repo?id=...`
|
||||
|
||||
Storage model:
|
||||
- Home Assistant storage key: `bcs_store`
|
||||
- Stored sections:
|
||||
- `custom_repos`
|
||||
- `installed_repos`
|
||||
- `settings`
|
||||
- `hacs_cache`
|
||||
- `repo_cache`
|
||||
|
||||
Frontend/UI facts:
|
||||
- The active HA panel script is `custom_components/bahmcloud_store/panel/panel.js`.
|
||||
- The richer UI is implemented there: source filtering, HACS toggle, repo detail loading, version selection, backups restore modal, restart action, and history handling.
|
||||
- `panel/app.js`, `panel/index.html`, and `panel/styles.css` look like older standalone or legacy panel assets. Treat them as secondary unless you confirm they are still used for a real path.
|
||||
|
||||
Code-analysis findings that should influence future work:
|
||||
- `store.py` represents an older store implementation with a different data model (`packages`, `source_path`, older API routes). It does not appear to be the active runtime path for the current integration flow.
|
||||
- `custom_repo_view.py` duplicates logic that already exists in `views.py` and looks legacy/unreferenced.
|
||||
- README files describe the project direction correctly at a high level, but some wording overstates provider generality. The actual code is provider-neutral in intent, but concretely implemented around GitHub, GitLab, and Gitea-style endpoints.
|
||||
- The end-user and full READMEs contain some stale or inconsistent details compared with the current UI and code. Verify behavior in source before using README text as specification.
|
||||
- There are visible encoding/mojibake issues in some documentation and older UI assets. Preserve valid UTF-8 when editing.
|
||||
|
||||
Planned product expansion:
|
||||
1. Add support for Home Assistant blueprints and install them directly into the correct Home Assistant blueprint location from the store.
|
||||
2. Add templates and Lovelace designs to the store so they can be discovered and installed from the same UI.
|
||||
3. Add support for more categories beyond integrations and design the architecture so category-specific install targets and validation rules are explicit.
|
||||
|
||||
Implications for future architecture:
|
||||
- The current install pipeline is integration-centric because it assumes repository content under `custom_components/`.
|
||||
- Future category support should move toward category-aware install strategies instead of one universal install path.
|
||||
- Store metadata and index entries will likely need stronger category typing, install-target definitions, and validation rules per category.
|
||||
|
||||
Project constraints to respect in future edits:
|
||||
- Keep async I/O non-blocking in Home Assistant.
|
||||
- Avoid startup-heavy network work before HA is fully started.
|
||||
- Preserve repo-cache and HACS-cache behavior unless intentionally redesigning refresh logic.
|
||||
- Preserve backup-before-overwrite safety for install/update/restore flows.
|
||||
- Preserve update-entity behavior for installed repos.
|
||||
- Keep the integration UI-admin-only and config-entry-based.
|
||||
|
||||
Recommended workflow for future tasks:
|
||||
1. Read `README.md`, `README_DEVELOPER.md`, and relevant source files.
|
||||
2. Verify whether the requested change belongs in active code or in legacy files.
|
||||
3. Implement the change in the active runtime path.
|
||||
4. Update documentation if behavior changed.
|
||||
5. Append a detailed dated entry to `.idea/changes.md`.
|
||||
6. If and only if a release is being prepared with user approval, fold release-worthy changes into `CHANGELOG.md`.
|
||||
|
||||
Current release baseline:
|
||||
- `manifest.json` version is `0.7.5`
|
||||
- Latest documented release in `CHANGELOG.md` is `0.7.5` dated `2026-03-23`
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
363
CHANGELOG.md
363
CHANGELOG.md
@@ -11,19 +11,362 @@ 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
|
||||
- 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
|
||||
- Repository metadata loaded on demand is now persisted and restored after Home Assistant restart.
|
||||
- Background enrichment reliably updates latest versions and descriptions for HACS repositories.
|
||||
- Cached repository data is correctly restored on startup for non-installed repositories.
|
||||
|
||||
### Changed
|
||||
- Repository details are only considered fully enriched once latest version information is available.
|
||||
- Improved reliability of background cache synchronization without impacting startup performance.
|
||||
|
||||
## [0.6.6] - 2026-01-18
|
||||
|
||||
### Added
|
||||
- Source filter to limit repositories by origin: BCS Official, HACS, or Custom.
|
||||
- Visual source badges for repositories (BCS Official, HACS, Custom).
|
||||
- Restored HACS enable/disable toggle in the Store UI.
|
||||
|
||||
### Changed
|
||||
- HACS repositories now display human-readable names and descriptions based on official HACS metadata.
|
||||
- Improved Store usability on mobile devices by fixing back navigation from repository detail view.
|
||||
|
||||
### Fixed
|
||||
- Fixed missing HACS toggle after UI updates.
|
||||
- Fixed mobile browser back button exiting the Store instead of returning to the repository list.
|
||||
|
||||
## [0.6.5] - 2026-01-18
|
||||
|
||||
### Added
|
||||
- Separate handling of HACS official repositories with an enable/disable toggle in the Store UI.
|
||||
- HACS repositories are now loaded independently from the main store index.
|
||||
|
||||
### Changed
|
||||
- Store index can remain minimal and curated; HACS repositories are no longer required in store.yaml.
|
||||
- Improved Store performance and clarity by clearly separating repository sources.
|
||||
|
||||
### Fixed
|
||||
- Browser cache issues resolved by proper panel cache-busting for UI updates.
|
||||
|
||||
### Internal
|
||||
- No changes to install, update, backup, or restore logic.
|
||||
- Fully backward compatible with existing installations and configurations.
|
||||
|
||||
## [0.6.4] - 2026-01-18
|
||||
|
||||
### Fixed
|
||||
- Fixed long Home Assistant startup times caused by background repository enrichment running too early.
|
||||
|
||||
### Changed
|
||||
- Background repository enrichment is now started only after Home Assistant has fully started.
|
||||
- Repository cache updates now run fully asynchronous without blocking Home Assistant startup.
|
||||
|
||||
### Internal
|
||||
- Improved alignment with Home Assistant startup lifecycle.
|
||||
- No functional changes to store behavior or UI.
|
||||
|
||||
## [0.6.3] - 2026-01-18
|
||||
|
||||
### Changed
|
||||
- Improved Store performance for large indexes by avoiding full metadata enrichment during list refresh.
|
||||
- Repository details are loaded on demand, reducing initial load time and network requests.
|
||||
- Index refresh is skipped when the index content has not changed.
|
||||
|
||||
## [0.6.2] - 2026-01-18
|
||||
|
||||
### Added
|
||||
- Selectable install/update version per repository (install older releases/tags to downgrade when needed).
|
||||
- New API endpoint to list available versions for a repository: `GET /api/bcs/versions?repo_id=...`.
|
||||
|
||||
## [0.6.1] - 2026-01-18
|
||||
|
||||
### Fixed
|
||||
- Restored integrations now correctly report the restored version instead of the latest installed version.
|
||||
- Update availability is correctly recalculated after restoring a backup, allowing updates to be applied again.
|
||||
- Improved restore compatibility with backups created before version metadata was introduced.
|
||||
|
||||
## [0.6.0] - 2026-01-18
|
||||
|
||||
### Added
|
||||
- Restore feature with selection of the last available backups (up to 5 per domain).
|
||||
- New API endpoints to list and restore backups:
|
||||
- `GET /api/bcs/backups?repo_id=...`
|
||||
- `POST /api/bcs/restore?repo_id=...&backup_id=...`
|
||||
|
||||
### Safety
|
||||
- Restoring a backup triggers a “restart required” prompt to apply the recovered integration state.
|
||||
|
||||
### Notes
|
||||
- This is a major release milestone consolidating install/update/uninstall, backup/rollback, and restore workflows.
|
||||
|
||||
## [0.5.11] - 2026-01-18
|
||||
|
||||
### Added
|
||||
- Automatic backup of existing custom components before install or update.
|
||||
- Backup retention with a configurable limit per domain.
|
||||
|
||||
### Safety
|
||||
- Automatic rollback is triggered if an install or update fails after a backup was created.
|
||||
|
||||
## [0.5.10] - 2026-01-17
|
||||
|
||||
### Added
|
||||
- Store view controls: Filter and Sort dropdowns alongside the existing Category selector.
|
||||
|
||||
### Fixed
|
||||
- Search input focus is preserved while typing (cursor no longer jumps out after re-render).
|
||||
|
||||
## [0.5.9] - 2026-01-17
|
||||
|
||||
### Changed
|
||||
- README is now collapsible with a preview by default (Show more / Show less).
|
||||
- Improved mobile readability by keeping long README content contained without affecting the page layout.
|
||||
|
||||
## [0.5.8] - 2026-01-17
|
||||
|
||||
### Changed
|
||||
- Mobile UI layout stabilized to prevent horizontal shifting.
|
||||
- README rendering no longer expands the page width on mobile devices.
|
||||
- Tables and code blocks inside README now scroll within their container.
|
||||
- Floating action buttons removed to avoid UI overlap on small screens.
|
||||
- Header icon buttons improved for better visibility in light and dark mode.
|
||||
|
||||
## [0.5.7] - 2026-01-17
|
||||
|
||||
### Changed
|
||||
- Mobile UI improvements: removed floating action buttons to prevent overlay issues.
|
||||
- Improved responsive layout to avoid horizontal overflow (badges, URLs, descriptions).
|
||||
- README rendering on mobile is more stable (better wrapping and image scaling).
|
||||
- Header icon buttons are more readable in both light and dark mode.
|
||||
|
||||
## [0.5.6] - 2026-01-17
|
||||
|
||||
### Added
|
||||
- Repository uninstall support directly from the Store UI.
|
||||
- New backend API endpoint: `POST /api/bcs/uninstall`.
|
||||
- Automatic **reconcile**: repositories are marked as not installed when their `custom_components` directories are removed manually.
|
||||
|
||||
### Changed
|
||||
- Installation & Updates section extended with an Uninstall button.
|
||||
- Store state now remains consistent even after manual file system changes.
|
||||
|
||||
### Fixed
|
||||
- Repositories remained marked as installed after manual deletion of their domains.
|
||||
- UI cache issues caused by outdated static assets.
|
||||
|
||||
## [0.5.5] - 2026-01-16
|
||||
|
||||
### Fixed
|
||||
- Update entities now refresh their displayed name after store refreshes, so repository names replace fallback IDs (e.g. `index:1`) reliably.
|
||||
|
||||
## [0.5.4] - 2026-01-16
|
||||
|
||||
### Added
|
||||
- Native **Repair fix flow** for restart-required situations.
|
||||
- “Restart required” issues are now **fixable** and provide a confirmation dialog with a real restart action.
|
||||
|
||||
### Changed
|
||||
- Restart-required issues are automatically cleared after Home Assistant restarts.
|
||||
- Update entities now fully align with official Home Assistant behavior (Updates screen + Repairs integration).
|
||||
|
||||
### Fixed
|
||||
- Fixed integration startup issues caused by incorrect file placement.
|
||||
- Resolved circular import and missing setup errors during Home Assistant startup.
|
||||
- Ensured YAML-based setup remains fully supported.
|
||||
|
||||
## [0.5.3] - 2026-01-16
|
||||
|
||||
### Added
|
||||
- Native Home Assistant Update entities for installed repositories (shown under **Settings → System → Updates**).
|
||||
- Human-friendly update names based on repository name (instead of internal repo IDs like `index:1`).
|
||||
|
||||
### Changed
|
||||
- Update UI now behaves like official Home Assistant integrations (update action is triggered via the HA Updates screen).
|
||||
|
||||
## [0.5.2] - 2026-01-16
|
||||
|
||||
### Added
|
||||
- Install and update backend endpoints (`POST /api/bcs/install`, `POST /api/bcs/update`) to install repositories into `/config/custom_components`.
|
||||
- Installed version tracking based on the actually installed ref (tag/release/branch), stored persistently to support repositories with outdated/`0.0.0` manifest versions.
|
||||
- API fields `installed_version` (installed ref) and `installed_manifest_version` (informational) to improve transparency in the UI.
|
||||
|
||||
### Changed
|
||||
- Update availability is now evaluated using the stored installed ref (instead of `manifest.json` version), preventing false-positive updates when repositories do not maintain manifest versions.
|
||||
|
||||
### Fixed
|
||||
- Repositories with `manifest.json` version `0.0.0` (or stale versions) no longer appear as constantly requiring updates after installing the latest release/tag.
|
||||
|
||||
## [0.5.0] - 2026-01-15
|
||||
|
||||
### Added
|
||||
- Manual refresh button that triggers a full backend refresh (store index + provider data).
|
||||
- Unified refresh pipeline: startup, timer and UI now use the same refresh logic.
|
||||
- Cache-busting for store index requests to always fetch the latest store.yaml.
|
||||
|
||||
### Improved
|
||||
- Logging for store index loading and parsing.
|
||||
- Refresh behavior now deterministic and verifiable via logs.
|
||||
|
||||
### Fixed
|
||||
- Refresh button previously only reloaded cached data.
|
||||
- Store index was not always reloaded immediately on user action.
|
||||
|
||||
## [0.4.1] - 2026-01-15
|
||||
### Fixed
|
||||
- Fixed GitLab README loading by using robust raw file endpoints.
|
||||
- Added support for nested GitLab groups when resolving README paths.
|
||||
- Added fallback handling for multiple README filenames (`README.md`, `README`, `README.rst`, etc.).
|
||||
- Added branch fallback logic for README loading (`default`, `main`, `master`).
|
||||
- Improved error resilience so README loading failures never break the store core.
|
||||
- No behavior change for GitHub and Gitea providers.
|
||||
|
||||
## [0.4.0] - 2026-01-15
|
||||
|
||||
### Added
|
||||
- Repository detail view (second page) in the Store UI.
|
||||
- README rendering using Home Assistant's `ha-markdown` element.
|
||||
- Floating action buttons (FAB):
|
||||
- Open repository
|
||||
- Reload README
|
||||
- Install (coming soon)
|
||||
- Update (coming soon)
|
||||
- Search field and category filter on the repository list page.
|
||||
- New authenticated API endpoint:
|
||||
- `GET /api/bcs/readme?repo_id=<id>` returns README markdown (best-effort).
|
||||
- Initial public release of the Bahmcloud Store integration.
|
||||
- Sidebar panel with repository browser UI.
|
||||
- Support for loading repositories from a central `store.yaml` index.
|
||||
- Support for custom repositories added by the user.
|
||||
- Provider abstraction for GitHub, GitLab and Gitea:
|
||||
- Fetch repository information (name, description, default branch).
|
||||
- Resolve latest version from:
|
||||
- Releases
|
||||
- Tags
|
||||
- Fallback mechanisms.
|
||||
- Repository metadata support via:
|
||||
- `bcs.yaml`
|
||||
- `hacs.yaml`
|
||||
- `hacs.json`
|
||||
- README loading and rendering pipeline:
|
||||
- Fetch raw README files.
|
||||
- Server-side Markdown rendering.
|
||||
- Sanitized HTML output for the panel UI.
|
||||
- Auto refresh mechanism for store index and repository metadata.
|
||||
- API endpoints:
|
||||
- List repositories
|
||||
- Add custom repository
|
||||
- Remove repository
|
||||
Persisted via Home Assistant storage (`.storage/bcs_store`).
|
||||
- Public static asset endpoint for panel JS (`/api/bahmcloud_store_static/...`) without auth (required for HA custom panels).
|
||||
- Initial API namespace:
|
||||
- `GET /api/bcs` list merged repositories (index + custom)
|
||||
- `POST /api/bcs` add custom repository
|
||||
- `DELETE /api/bcs/custom_repo` remove custom repository
|
||||
|
||||
### Changed
|
||||
- Repository cards are now clickable to open the detail view.
|
||||
|
||||
87
README.md
87
README.md
@@ -1,3 +1,86 @@
|
||||
# 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 content hosted on git platforms.
|
||||
It currently supports installing and managing:
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
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**: 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: `README_DEVELOPER.md`
|
||||
- Full user guide: `README_FULL.md`
|
||||
|
||||
159
README_DEVELOPER.md
Normal file
159
README_DEVELOPER.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# Bahmcloud Store - Developer Documentation
|
||||
|
||||
For contributors and maintainers.
|
||||
|
||||
## Project Scope
|
||||
|
||||
Bahmcloud Store is evolving from an integration-only store into a broader Home Assistant git-based content store.
|
||||
|
||||
Currently supported install categories:
|
||||
|
||||
- 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 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
|
||||
|
||||
Metadata priority:
|
||||
|
||||
1. `bcs.yaml`
|
||||
2. `hacs.yaml`
|
||||
3. `hacs.json`
|
||||
|
||||
Common fields:
|
||||
|
||||
```yaml
|
||||
name: Example Project
|
||||
description: Short description
|
||||
category: Integrations
|
||||
author: Example Author
|
||||
maintainer: Example Maintainer
|
||||
```
|
||||
|
||||
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: Example Project
|
||||
url: https://your-git-hoster.example/org/repo
|
||||
category: Blueprint
|
||||
```
|
||||
|
||||
Recommended repository metadata:
|
||||
|
||||
```yaml
|
||||
name: Example Project
|
||||
description: One-line description
|
||||
category: Blueprint
|
||||
author: Example Author
|
||||
maintainer: Example Maintainer
|
||||
```
|
||||
|
||||
Validation should match the category:
|
||||
|
||||
- Integrations: verify `custom_components/<domain>/manifest.json`
|
||||
- Blueprints: verify `blueprints/...`
|
||||
|
||||
## 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.
|
||||
324
README_FULL.md
Normal file
324
README_FULL.md
Normal file
@@ -0,0 +1,324 @@
|
||||
# Bahmcloud Store - Full User Guide
|
||||
|
||||
This guide explains the current feature set of Bahmcloud Store (BCS) for Home Assistant.
|
||||
|
||||
BCS is a git-based store UI that can currently manage integrations and blueprints from multiple repository providers.
|
||||
|
||||
---
|
||||
|
||||
## Contents
|
||||
|
||||
- Concepts
|
||||
- Sources
|
||||
- Supported Content Types
|
||||
- UI Overview
|
||||
- Finding Repositories
|
||||
- Installing
|
||||
- Selecting Versions
|
||||
- Updating
|
||||
- Uninstalling
|
||||
- Pinned Repositories
|
||||
- Release Notes
|
||||
- Backups and Restore
|
||||
- Custom Repositories
|
||||
- HACS Repositories
|
||||
- Update Entities
|
||||
- Performance and Caching
|
||||
- Restart Behavior
|
||||
- Troubleshooting
|
||||
- FAQ
|
||||
|
||||
---
|
||||
|
||||
## Concepts
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
## Supported Content Types
|
||||
|
||||
### Integrations
|
||||
|
||||
- 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
|
||||
|
||||
Store view:
|
||||
|
||||
- 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 Repositories
|
||||
|
||||
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, metadata, and latest versions may appear progressively because BCS enriches repository information in the background.
|
||||
|
||||
---
|
||||
|
||||
## Installing
|
||||
|
||||
### Integration Install
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
### Integrations
|
||||
|
||||
- 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
|
||||
|
||||
### 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
|
||||
|
||||
---
|
||||
|
||||
## Pinned Repositories
|
||||
|
||||
You can pin repositories to keep important items easy to find.
|
||||
|
||||
Current behavior:
|
||||
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
## 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 URL that matches a supported provider style and contains supported content.
|
||||
|
||||
BCS attempts to detect:
|
||||
|
||||
- provider
|
||||
- default branch
|
||||
- latest version
|
||||
- metadata from `bcs.yaml`, `hacs.yaml`, or `hacs.json`
|
||||
- README content
|
||||
|
||||
Custom repositories get a **Custom** badge and can be filtered separately.
|
||||
|
||||
---
|
||||
|
||||
## HACS Repositories
|
||||
|
||||
Enable the HACS toggle to include official HACS integration repositories.
|
||||
|
||||
Current scope:
|
||||
|
||||
- HACS source support is currently focused on integrations
|
||||
- HACS metadata is used to improve naming and descriptions
|
||||
|
||||
---
|
||||
|
||||
## Update Entities
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Performance and Caching
|
||||
|
||||
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 Behavior
|
||||
|
||||
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
|
||||
|
||||
- **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
|
||||
|
||||
### 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.
|
||||
19
bcs.yaml
Normal file
19
bcs.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
name: Bahmcloud Store
|
||||
description: >
|
||||
Provider-neutral custom integration store for Home Assistant.
|
||||
Supports GitHub, GitLab, Gitea and Bahmcloud repositories with
|
||||
a central index, UI panel and API, similar to HACS but independent.
|
||||
|
||||
category: Integrations
|
||||
|
||||
author: Bahmcloud
|
||||
maintainer: Bahmcloud
|
||||
|
||||
domains:
|
||||
- bahmcloud_store
|
||||
|
||||
min_ha_version: "2024.1.0"
|
||||
|
||||
homepage: https://git.bahmcloud.de/bahmcloud/bahmcloud_store
|
||||
issues: https://git.bahmcloud.de/bahmcloud/bahmcloud_store/issues
|
||||
source: https://git.bahmcloud.de/bahmcloud/bahmcloud_store
|
||||
@@ -3,57 +3,133 @@ from __future__ import annotations
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.helpers.discovery import async_load_platform
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.components.panel_custom import async_register_panel
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||
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, {})
|
||||
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
|
||||
|
||||
await core.register_http_views()
|
||||
|
||||
# RESTORE: keep the module_url pattern that worked for you
|
||||
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()
|
||||
|
||||
# HTTP views + panel (registered once per entry; we only allow one entry).
|
||||
from .views import (
|
||||
StaticAssetsView,
|
||||
BCSApiView,
|
||||
BCSSettingsView,
|
||||
BCSReadmeView,
|
||||
BCSVersionsView,
|
||||
BCSReleaseNotesView,
|
||||
BCSRepoDetailView,
|
||||
BCSCustomRepoView,
|
||||
BCSInstallView,
|
||||
BCSUpdateView,
|
||||
BCSUninstallView,
|
||||
BCSBackupsView,
|
||||
BCSRestoreView,
|
||||
BCSRestartView,
|
||||
)
|
||||
|
||||
hass.http.register_view(StaticAssetsView())
|
||||
hass.http.register_view(BCSApiView(core))
|
||||
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))
|
||||
hass.http.register_view(BCSUpdateView(core))
|
||||
hass.http.register_view(BCSUninstallView(core))
|
||||
hass.http.register_view(BCSBackupsView(core))
|
||||
hass.http.register_view(BCSRestoreView(core))
|
||||
hass.http.register_view(BCSRestartView(core))
|
||||
|
||||
await async_register_panel(
|
||||
hass,
|
||||
frontend_url_path="bahmcloud-store",
|
||||
webcomponent_name="bahmcloud-store-panel",
|
||||
module_url="/api/bahmcloud_store_static/panel.js?v=42",
|
||||
# IMPORTANT: bump v to avoid caching old JS
|
||||
module_url="/api/bahmcloud_store_static/panel.js?v=112",
|
||||
sidebar_title="Bahmcloud Store",
|
||||
sidebar_icon="mdi:store",
|
||||
require_admin=True,
|
||||
config={},
|
||||
)
|
||||
|
||||
async def _do_startup_refresh(_now=None) -> None:
|
||||
try:
|
||||
await core.refresh()
|
||||
await core.full_refresh(source="startup")
|
||||
except BCSError as e:
|
||||
_LOGGER.error("Initial refresh failed: %s", e)
|
||||
|
||||
# Do not block startup; refresh after HA is up.
|
||||
def _on_ha_started(_event) -> None:
|
||||
async_call_later(hass, 30, _do_startup_refresh)
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _on_ha_started)
|
||||
|
||||
async def periodic(_now) -> None:
|
||||
try:
|
||||
await core.refresh()
|
||||
core.signal_updated()
|
||||
await core.full_refresh(source="timer")
|
||||
except BCSError as e:
|
||||
_LOGGER.warning("Periodic refresh failed: %s", e)
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
_LOGGER.exception("Unexpected error during periodic refresh: %s", e)
|
||||
|
||||
interval = timedelta(seconds=int(core.refresh_seconds or 300))
|
||||
async_track_time_interval(hass, periodic, interval)
|
||||
interval_seconds = int(getattr(core, "refresh_seconds", 300) or 300)
|
||||
async_track_time_interval(hass, periodic, timedelta(seconds=interval_seconds))
|
||||
|
||||
await async_load_platform(hass, Platform.UPDATE, DOMAIN, {}, config)
|
||||
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
|
||||
|
||||
71
custom_components/bahmcloud_store/config_flow.py
Normal file
71
custom_components/bahmcloud_store/config_flow.py
Normal 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)
|
||||
11
custom_components/bahmcloud_store/const.py
Normal file
11
custom_components/bahmcloud_store/const.py
Normal 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"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"domain": "bahmcloud_store",
|
||||
"name": "Bahmcloud Store",
|
||||
"version": "0.4.0",
|
||||
"version": "0.7.6",
|
||||
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
|
||||
"config_flow": true,
|
||||
"platforms": ["update"],
|
||||
"requirements": [],
|
||||
"codeowners": [],
|
||||
"codeowners": ["@bahmcloud"],
|
||||
"iot_class": "local_polling"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,24 @@
|
||||
body { font-family: system-ui, sans-serif; margin:0; }
|
||||
.wrap { padding: 16px; max-width: 1000px; margin: 0 auto; }
|
||||
.card { border: 1px solid #ddd; border-radius: 10px; padding: 12px; margin: 10px 0; }
|
||||
.row { display:flex; justify-content:space-between; gap: 12px; align-items: flex-start; }
|
||||
.row { display:flex; justify-content:space-between; gap: 12px; align-items: flex-start; flex-wrap: wrap; min-width:0; }
|
||||
.badge { border: 1px solid #bbb; border-radius: 999px; padding: 2px 8px; font-size: 12px; height: fit-content; }
|
||||
.muted { color: #666; font-size: 13px; margin-top: 4px; }
|
||||
.actions { display:flex; gap: 8px; margin-top: 10px; }
|
||||
button { padding: 8px 12px; cursor:pointer; }
|
||||
button[disabled] { opacity: 0.6; cursor: not-allowed; }
|
||||
a { color: inherit; }
|
||||
|
||||
/* Basic markdown safety (in case styles.css is used by older panels) */
|
||||
.md { max-width: 100%; overflow-x: auto; }
|
||||
.md table { display:block; max-width:100%; overflow-x:auto; }
|
||||
.md img { max-width: 100%; height: auto; }
|
||||
|
||||
/* README UX (E2): collapsible preview (standalone page only) */
|
||||
.readmeWrap{ border:1px solid #ddd; border-radius:10px; padding:12px; background: #f7f7f7; max-width:100%; }
|
||||
.readmeWrap.collapsed{ max-height:260px; overflow:hidden; position:relative; }
|
||||
.readmeWrap.collapsed::after{ content:""; position:absolute; left:0; right:0; bottom:0; height:56px; background: linear-gradient(to bottom, rgba(247,247,247,0), #f7f7f7); pointer-events:none; }
|
||||
.readmeWrap.expanded{ max-height:70vh; overflow:auto; }
|
||||
.readmeActions{ display:flex; justify-content:flex-end; margin-top:10px; }
|
||||
button.link{ border:none; background:transparent; padding:6px 10px; color:#1E88E5; }
|
||||
button.link:hover{ text-decoration:underline; }
|
||||
|
||||
@@ -6,6 +6,8 @@ import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import quote_plus, urlparse
|
||||
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
@@ -51,13 +53,8 @@ def detect_provider(repo_url: str) -> str:
|
||||
return "github"
|
||||
if "gitlab" in host:
|
||||
return "gitlab"
|
||||
|
||||
owner, repo = _split_owner_repo(repo_url)
|
||||
if owner and repo:
|
||||
return "gitea"
|
||||
|
||||
return "generic"
|
||||
|
||||
|
||||
async def _safe_json(session, url: str, *, headers: dict | None = None, timeout: int = 20):
|
||||
try:
|
||||
@@ -82,211 +79,257 @@ async def _safe_text(session, url: str, *, headers: dict | None = None, timeout:
|
||||
|
||||
|
||||
def _extract_tag_from_github_url(url: str) -> str | None:
|
||||
m = re.search(r"/releases/tag/([^/?#]+)", url)
|
||||
if m:
|
||||
return m.group(1)
|
||||
m = re.search(r"/tag/([^/?#]+)", url)
|
||||
if m:
|
||||
return m.group(1)
|
||||
m = re.search(r"/releases/tag/([^/?#]+)", url or "")
|
||||
if not m:
|
||||
return None
|
||||
|
||||
|
||||
def _strip_html(s: str) -> str:
|
||||
# minimal HTML entity cleanup for meta descriptions
|
||||
out = (
|
||||
s.replace("&", "&")
|
||||
.replace(""", '"')
|
||||
.replace("'", "'")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
return re.sub(r"\s+", " ", out).strip()
|
||||
return m.group(1).strip() or None
|
||||
|
||||
|
||||
def _extract_meta(html: str, *, prop: str | None = None, name: str | None = None) -> str | None:
|
||||
# Extract <meta property="og:description" content="...">
|
||||
# or <meta name="description" content="...">
|
||||
if not html:
|
||||
return None
|
||||
if prop:
|
||||
# property="..." content="..."
|
||||
m = re.search(
|
||||
r'<meta[^>]+property=["\']' + re.escape(prop) + r'["\'][^>]+content=["\']([^"\']+)["\']',
|
||||
html,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
m = re.search(rf'<meta\s+property="{re.escape(prop)}"\s+content="([^"]+)"', html)
|
||||
if m:
|
||||
return _strip_html(m.group(1))
|
||||
m = re.search(
|
||||
r'<meta[^>]+content=["\']([^"\']+)["\'][^>]+property=["\']' + re.escape(prop) + r'["\']',
|
||||
html,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if m:
|
||||
return _strip_html(m.group(1))
|
||||
|
||||
return m.group(1).strip()
|
||||
if name:
|
||||
m = re.search(
|
||||
r'<meta[^>]+name=["\']' + re.escape(name) + r'["\'][^>]+content=["\']([^"\']+)["\']',
|
||||
html,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
m = re.search(rf'<meta\s+name="{re.escape(name)}"\s+content="([^"]+)"', html)
|
||||
if m:
|
||||
return _strip_html(m.group(1))
|
||||
m = re.search(
|
||||
r'<meta[^>]+content=["\']([^"\']+)["\'][^>]+name=["\']' + re.escape(name) + r'["\']',
|
||||
html,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if m:
|
||||
return _strip_html(m.group(1))
|
||||
|
||||
return m.group(1).strip()
|
||||
return None
|
||||
|
||||
|
||||
async def _github_description_html(hass: HomeAssistant, owner: str, repo: str) -> str | None:
|
||||
"""
|
||||
GitHub API may be rate-limited; fetch public HTML and read meta description.
|
||||
"""
|
||||
session = async_get_clientsession(hass)
|
||||
headers = {
|
||||
"User-Agent": UA,
|
||||
"Accept": "text/html,application/xhtml+xml",
|
||||
}
|
||||
def _semver_key(tag: str) -> Version | None:
|
||||
t = (tag or "").strip()
|
||||
if not t:
|
||||
return None
|
||||
if t.startswith(("v", "V")):
|
||||
t = t[1:]
|
||||
try:
|
||||
return Version(t)
|
||||
except InvalidVersion:
|
||||
return None
|
||||
|
||||
html, status = await _safe_text(session, f"https://github.com/{owner}/{repo}", headers=headers)
|
||||
if not html or status != 200:
|
||||
|
||||
def _pick_highest_semver(tags: list[str]) -> str | None:
|
||||
parsed: list[tuple[Version, str]] = []
|
||||
for t in tags:
|
||||
if not isinstance(t, str):
|
||||
continue
|
||||
ts = t.strip()
|
||||
if not ts:
|
||||
continue
|
||||
v = _semver_key(ts)
|
||||
if v is not None:
|
||||
parsed.append((v, ts))
|
||||
|
||||
if not parsed:
|
||||
return None
|
||||
parsed.sort(key=lambda x: x[0], reverse=True)
|
||||
return parsed[0][1]
|
||||
|
||||
|
||||
async def _github_description_html(hass: HomeAssistant, owner: str, repo: str) -> str | None:
|
||||
session = async_get_clientsession(hass)
|
||||
url = f"https://github.com/{owner}/{repo}"
|
||||
html, status = await _safe_text(session, url, headers={"User-Agent": UA})
|
||||
if status != 200 or not html:
|
||||
return None
|
||||
|
||||
desc = _extract_meta(html, prop="og:description")
|
||||
if desc:
|
||||
return desc
|
||||
|
||||
desc = _extract_meta(html, name="description")
|
||||
if desc:
|
||||
return desc
|
||||
|
||||
return None
|
||||
return _extract_meta(html, name="description")
|
||||
|
||||
|
||||
async def _github_latest_version_atom(hass: HomeAssistant, owner: str, repo: str) -> tuple[str | None, str | None]:
|
||||
session = async_get_clientsession(hass)
|
||||
headers = {"User-Agent": UA, "Accept": "application/atom+xml,text/xml;q=0.9,*/*;q=0.8"}
|
||||
|
||||
xml_text, _ = await _safe_text(session, f"https://github.com/{owner}/{repo}/releases.atom", headers=headers)
|
||||
if not xml_text:
|
||||
url = f"https://github.com/{owner}/{repo}/releases.atom"
|
||||
atom, status = await _safe_text(session, url, headers={"User-Agent": UA})
|
||||
if status != 200 or not atom:
|
||||
return None, None
|
||||
|
||||
try:
|
||||
root = ET.fromstring(xml_text)
|
||||
except Exception:
|
||||
root = ET.fromstring(atom)
|
||||
ns = {"a": "http://www.w3.org/2005/Atom"}
|
||||
entry = root.find("a:entry", ns)
|
||||
if entry is None:
|
||||
return None, None
|
||||
|
||||
for entry in root.findall(".//{*}entry"):
|
||||
for link in entry.findall(".//{*}link"):
|
||||
href = link.attrib.get("href")
|
||||
if not href:
|
||||
continue
|
||||
tag = _extract_tag_from_github_url(href)
|
||||
link = entry.find("a:link", ns)
|
||||
if link is not None and link.attrib.get("href"):
|
||||
tag = _extract_tag_from_github_url(link.attrib["href"])
|
||||
if tag:
|
||||
return tag, "atom"
|
||||
title = entry.find("a:title", ns)
|
||||
if title is not None and title.text:
|
||||
t = title.text.strip()
|
||||
if t:
|
||||
return t, "atom"
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
async def _github_latest_version_redirect(hass: HomeAssistant, owner: str, repo: str) -> tuple[str | None, str | None]:
|
||||
session = async_get_clientsession(hass)
|
||||
headers = {"User-Agent": UA}
|
||||
url = f"https://github.com/{owner}/{repo}/releases/latest"
|
||||
try:
|
||||
async with session.head(url, allow_redirects=False, timeout=15, headers=headers) as resp:
|
||||
if resp.status in (301, 302, 303, 307, 308):
|
||||
loc = resp.headers.get("Location")
|
||||
if loc:
|
||||
tag = _extract_tag_from_github_url(loc)
|
||||
async with session.get(url, timeout=20, headers={"User-Agent": UA}, allow_redirects=True) as resp:
|
||||
if resp.status != 200:
|
||||
return None, None
|
||||
final = str(resp.url)
|
||||
tag = _extract_tag_from_github_url(final)
|
||||
if tag:
|
||||
return tag, "release"
|
||||
except Exception:
|
||||
pass
|
||||
return None, None
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
async def _github_latest_version_api(hass: HomeAssistant, owner: str, repo: str) -> tuple[str | None, str | None]:
|
||||
async def _github_latest_version_api(
|
||||
hass: HomeAssistant, owner: str, repo: str, *, github_token: str | None = None
|
||||
) -> tuple[str | None, str | None]:
|
||||
session = async_get_clientsession(hass)
|
||||
headers = {"Accept": "application/vnd.github+json", "User-Agent": UA}
|
||||
headers = _github_headers(github_token)
|
||||
|
||||
data, _ = await _safe_json(session, f"https://api.github.com/repos/{owner}/{repo}/releases/latest", headers=headers)
|
||||
if isinstance(data, dict):
|
||||
tag = data.get("tag_name") or data.get("name")
|
||||
if isinstance(tag, str) and tag.strip():
|
||||
return tag.strip(), "release"
|
||||
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"):
|
||||
return str(data["tag_name"]), "release"
|
||||
|
||||
data, _ = await _safe_json(session, f"https://api.github.com/repos/{owner}/{repo}/tags?per_page=1", headers=headers)
|
||||
if isinstance(data, list) and data:
|
||||
tag = data[0].get("name")
|
||||
if isinstance(tag, str) and tag.strip():
|
||||
return tag.strip(), "tag"
|
||||
# No releases -> pick highest semver from many tags (instead of per_page=1)
|
||||
if status == 404:
|
||||
data, _ = await _safe_json(session, f"https://api.github.com/repos/{owner}/{repo}/tags?per_page=100", headers=headers)
|
||||
tags: list[str] = []
|
||||
if isinstance(data, list):
|
||||
for t in data:
|
||||
if isinstance(t, dict) and t.get("name"):
|
||||
tags.append(str(t["name"]))
|
||||
|
||||
best = _pick_highest_semver(tags)
|
||||
if best:
|
||||
return best, "tag"
|
||||
|
||||
# fallback: keep old behavior (first tag)
|
||||
if tags:
|
||||
return tags[0], "tag"
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
async def _github_latest_version(hass: HomeAssistant, owner: str, repo: str) -> tuple[str | None, str | None]:
|
||||
tag, src = await _github_latest_version_atom(hass, owner, repo)
|
||||
if tag:
|
||||
return tag, src
|
||||
|
||||
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
|
||||
|
||||
return 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
|
||||
|
||||
return await _github_latest_version_atom(hass, owner, repo)
|
||||
|
||||
|
||||
async def _gitea_latest_version(hass: HomeAssistant, base: str, owner: str, repo: str) -> tuple[str | None, str | None]:
|
||||
session = async_get_clientsession(hass)
|
||||
|
||||
data, _ = await _safe_json(session, f"{base}/api/v1/repos/{owner}/{repo}/releases?limit=1")
|
||||
if isinstance(data, list) and data:
|
||||
tag = data[0].get("tag_name") or data[0].get("name")
|
||||
if isinstance(tag, str) and tag.strip():
|
||||
return tag.strip(), "release"
|
||||
# releases: fetch multiple, pick highest semver (instead of limit=1)
|
||||
data, _ = await _safe_json(session, f"{base}/api/v1/repos/{owner}/{repo}/releases?limit=50")
|
||||
rel_tags: list[str] = []
|
||||
if isinstance(data, list):
|
||||
for r in data:
|
||||
if isinstance(r, dict) and r.get("tag_name"):
|
||||
rel_tags.append(str(r["tag_name"]))
|
||||
|
||||
data, _ = await _safe_json(session, f"{base}/api/v1/repos/{owner}/{repo}/tags?limit=1")
|
||||
if isinstance(data, list) and data:
|
||||
tag = data[0].get("name")
|
||||
if isinstance(tag, str) and tag.strip():
|
||||
return tag.strip(), "tag"
|
||||
best_rel = _pick_highest_semver(rel_tags)
|
||||
if best_rel:
|
||||
return best_rel, "release"
|
||||
if rel_tags:
|
||||
return rel_tags[0], "release"
|
||||
|
||||
# tags: fetch multiple, pick highest semver (instead of limit=1)
|
||||
data, _ = await _safe_json(session, f"{base}/api/v1/repos/{owner}/{repo}/tags?limit=50")
|
||||
tags: list[str] = []
|
||||
if isinstance(data, list):
|
||||
for t in data:
|
||||
if isinstance(t, dict) and t.get("name"):
|
||||
tags.append(str(t["name"]))
|
||||
|
||||
best = _pick_highest_semver(tags)
|
||||
if best:
|
||||
return best, "tag"
|
||||
if tags:
|
||||
return tags[0], "tag"
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
async def _gitlab_latest_version(hass: HomeAssistant, base: str, owner: str, repo: str) -> tuple[str | None, str | None]:
|
||||
async def _gitlab_latest_version(
|
||||
hass: HomeAssistant, base: str, owner: str, repo: str
|
||||
) -> tuple[str | None, str | None]:
|
||||
session = async_get_clientsession(hass)
|
||||
headers = {"User-Agent": UA}
|
||||
|
||||
project = quote_plus(f"{owner}/{repo}")
|
||||
|
||||
data, _ = await _safe_json(
|
||||
session,
|
||||
f"{base}/api/v4/projects/{project}/releases?per_page=1&order_by=released_at&sort=desc",
|
||||
headers=headers,
|
||||
)
|
||||
if isinstance(data, list) and data:
|
||||
tag = data[0].get("tag_name") or data[0].get("name")
|
||||
if isinstance(tag, str) and tag.strip():
|
||||
return tag.strip(), "release"
|
||||
# releases: fetch multiple, pick highest semver (instead of per_page=1)
|
||||
data, _ = await _safe_json(session, f"{base}/api/v4/projects/{project}/releases?per_page=50", headers=headers)
|
||||
rel_tags: list[str] = []
|
||||
if isinstance(data, list):
|
||||
for r in data:
|
||||
if isinstance(r, dict) and r.get("tag_name"):
|
||||
rel_tags.append(str(r["tag_name"]))
|
||||
|
||||
data, _ = await _safe_json(
|
||||
session,
|
||||
f"{base}/api/v4/projects/{project}/repository/tags?per_page=1&order_by=updated&sort=desc",
|
||||
headers=headers,
|
||||
)
|
||||
if isinstance(data, list) and data:
|
||||
tag = data[0].get("name")
|
||||
if isinstance(tag, str) and tag.strip():
|
||||
return tag.strip(), "tag"
|
||||
best_rel = _pick_highest_semver(rel_tags)
|
||||
if best_rel:
|
||||
return best_rel, "release"
|
||||
if rel_tags:
|
||||
return rel_tags[0], "release"
|
||||
|
||||
# tags: fetch multiple, pick highest semver (instead of per_page=1)
|
||||
data, _ = await _safe_json(session, f"{base}/api/v4/projects/{project}/repository/tags?per_page=50", headers=headers)
|
||||
tags: list[str] = []
|
||||
if isinstance(data, list):
|
||||
for t in data:
|
||||
if isinstance(t, dict) and t.get("name"):
|
||||
tags.append(str(t["name"]))
|
||||
|
||||
best = _pick_highest_semver(tags)
|
||||
if best:
|
||||
return best, "tag"
|
||||
if tags:
|
||||
return tags[0], "tag"
|
||||
|
||||
# atom fallback
|
||||
atom, status = await _safe_text(session, f"{base}/{owner}/{repo}/-/tags?format=atom", headers=headers)
|
||||
if status == 200 and atom:
|
||||
try:
|
||||
root = ET.fromstring(atom)
|
||||
ns = {"a": "http://www.w3.org/2005/Atom"}
|
||||
entry = root.find("a:entry", ns)
|
||||
if entry is not None:
|
||||
title = entry.find("a:title", ns)
|
||||
if title is not None and title.text:
|
||||
return title.text.strip(), "atom"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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)
|
||||
|
||||
@@ -307,8 +350,7 @@ async def fetch_repo_info(hass: HomeAssistant, repo_url: str) -> RepoInfo:
|
||||
|
||||
try:
|
||||
if provider == "github":
|
||||
# Try API repo details (may be rate-limited)
|
||||
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):
|
||||
@@ -318,18 +360,16 @@ async def fetch_repo_info(hass: HomeAssistant, repo_url: str) -> RepoInfo:
|
||||
if isinstance(data.get("owner"), dict) and data["owner"].get("login"):
|
||||
info.owner = data["owner"]["login"]
|
||||
else:
|
||||
# If API blocked, still set reasonable defaults
|
||||
if status == 403:
|
||||
_LOGGER.debug("GitHub API blocked/rate-limited for repo info %s/%s", owner, repo)
|
||||
info.default_branch = "main"
|
||||
|
||||
# If description missing, fetch from GitHub HTML
|
||||
if not info.description:
|
||||
desc = await _github_description_html(hass, owner, repo)
|
||||
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
|
||||
@@ -371,8 +411,345 @@ async def fetch_repo_info(hass: HomeAssistant, repo_url: str) -> RepoInfo:
|
||||
info.latest_version_source = src
|
||||
return info
|
||||
|
||||
except Exception as e:
|
||||
_LOGGER.debug("fetch_repo_info failed for %s: %s", repo_url, e)
|
||||
|
||||
return info
|
||||
|
||||
except Exception as e:
|
||||
_LOGGER.debug("Provider fetch failed for %s: %s", repo_url, e)
|
||||
return info
|
||||
|
||||
async def fetch_readme_markdown(
|
||||
hass: HomeAssistant,
|
||||
repo_url: str,
|
||||
*,
|
||||
provider: str | None = None,
|
||||
default_branch: str | None = None,
|
||||
) -> str | None:
|
||||
"""Fetch README Markdown for public repositories (GitHub/GitLab/Gitea).
|
||||
|
||||
Defensive behavior:
|
||||
- tries multiple common README filenames
|
||||
- tries multiple branches (default, main, master)
|
||||
- uses public raw endpoints (no tokens required for public repositories)
|
||||
"""
|
||||
repo_url = (repo_url or "").strip()
|
||||
if not repo_url:
|
||||
return None
|
||||
|
||||
prov = (provider or "").strip().lower() if provider else ""
|
||||
if not prov:
|
||||
prov = detect_provider(repo_url)
|
||||
|
||||
branch_candidates: list[str] = []
|
||||
if default_branch and str(default_branch).strip():
|
||||
branch_candidates.append(str(default_branch).strip())
|
||||
for b in ("main", "master"):
|
||||
if b not in branch_candidates:
|
||||
branch_candidates.append(b)
|
||||
|
||||
filenames = ["README.md", "readme.md", "README.MD", "README.rst", "README"]
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
headers = {"User-Agent": UA}
|
||||
|
||||
def _normalize_gitlab_path(path: str) -> str | None:
|
||||
p = (path or "").strip().strip("/")
|
||||
if not p:
|
||||
return None
|
||||
parts = [x for x in p.split("/") if x]
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
if parts[-1].endswith(".git"):
|
||||
parts[-1] = parts[-1][:-4]
|
||||
return "/".join(parts)
|
||||
|
||||
candidates: list[str] = []
|
||||
|
||||
if prov == "github":
|
||||
owner, repo = _split_owner_repo(repo_url)
|
||||
if not owner or not repo:
|
||||
return None
|
||||
for branch in branch_candidates:
|
||||
base = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}"
|
||||
for fn in filenames:
|
||||
candidates.append(f"{base}/{fn}")
|
||||
|
||||
elif prov == "gitea":
|
||||
owner, repo = _split_owner_repo(repo_url)
|
||||
if not owner or not repo:
|
||||
return None
|
||||
u = urlparse(repo_url.rstrip("/"))
|
||||
root = f"{u.scheme}://{u.netloc}/{owner}/{repo}"
|
||||
for branch in branch_candidates:
|
||||
bases = [
|
||||
f"{root}/raw/branch/{branch}",
|
||||
f"{root}/raw/{branch}",
|
||||
]
|
||||
for b in bases:
|
||||
for fn in filenames:
|
||||
candidates.append(f"{b}/{fn}")
|
||||
|
||||
elif prov == "gitlab":
|
||||
u = urlparse(repo_url.rstrip("/"))
|
||||
path_repo = _normalize_gitlab_path(u.path)
|
||||
if not path_repo:
|
||||
return None
|
||||
root = f"{u.scheme}://{u.netloc}/{path_repo}"
|
||||
for branch in branch_candidates:
|
||||
bases = [
|
||||
f"{root}/-/raw/{branch}",
|
||||
f"{root}/raw/{branch}",
|
||||
]
|
||||
for b in bases:
|
||||
for fn in filenames:
|
||||
candidates.append(f"{b}/{fn}")
|
||||
|
||||
else:
|
||||
return None
|
||||
|
||||
for url in candidates:
|
||||
try:
|
||||
async with session.get(url, timeout=20, headers=headers) as resp:
|
||||
if resp.status != 200:
|
||||
continue
|
||||
txt = await resp.text()
|
||||
if txt and txt.strip():
|
||||
return txt
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_repo_versions(
|
||||
hass: HomeAssistant,
|
||||
repo_url: str,
|
||||
*,
|
||||
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.
|
||||
|
||||
Returns a list of dicts with keys:
|
||||
- ref: the ref to install (tag/release/branch)
|
||||
- label: human-friendly label
|
||||
- source: release|tag|branch
|
||||
|
||||
Notes:
|
||||
- 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.
|
||||
"""
|
||||
|
||||
repo_url = (repo_url or "").strip()
|
||||
if not repo_url:
|
||||
return []
|
||||
|
||||
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 []
|
||||
|
||||
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()
|
||||
|
||||
def _add(ref: str | None, label: str, source: str) -> None:
|
||||
r = (ref or "").strip()
|
||||
if not r or r in seen:
|
||||
return
|
||||
seen.add(r)
|
||||
out.append({"ref": r, "label": label, "source": source})
|
||||
|
||||
# Always offer default branch as an explicit option.
|
||||
if default_branch and str(default_branch).strip():
|
||||
b = str(default_branch).strip()
|
||||
_add(b, f"Branch: {b}", "branch")
|
||||
|
||||
try:
|
||||
if prov == "github":
|
||||
# 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={per_page}",
|
||||
headers=gh_headers,
|
||||
)
|
||||
if isinstance(data, list):
|
||||
for r in data:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
tag = r.get("tag_name")
|
||||
name = r.get("name")
|
||||
if tag:
|
||||
lbl = str(tag)
|
||||
if isinstance(name, str) and name.strip() and name.strip() != str(tag):
|
||||
lbl = f"{tag} — {name.strip()}"
|
||||
_add(str(tag), lbl, "release")
|
||||
|
||||
# Tags
|
||||
data, _ = await _safe_json(
|
||||
session,
|
||||
f"https://api.github.com/repos/{owner}/{repo}/tags?per_page={per_page}",
|
||||
headers=gh_headers,
|
||||
)
|
||||
if isinstance(data, list):
|
||||
for t in data:
|
||||
if isinstance(t, dict) and t.get("name"):
|
||||
_add(str(t["name"]), str(t["name"]), "tag")
|
||||
|
||||
return out
|
||||
|
||||
if prov == "gitlab":
|
||||
u = urlparse(repo_url.rstrip("/"))
|
||||
base = f"{u.scheme}://{u.netloc}"
|
||||
project = quote_plus(f"{owner}/{repo}")
|
||||
|
||||
data, _ = await _safe_json(
|
||||
session,
|
||||
f"{base}/api/v4/projects/{project}/releases?per_page={int(limit)}",
|
||||
headers=headers,
|
||||
)
|
||||
if isinstance(data, list):
|
||||
for r in data:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
tag = r.get("tag_name")
|
||||
name = r.get("name")
|
||||
if tag:
|
||||
lbl = str(tag)
|
||||
if isinstance(name, str) and name.strip() and name.strip() != str(tag):
|
||||
lbl = f"{tag} — {name.strip()}"
|
||||
_add(str(tag), lbl, "release")
|
||||
|
||||
data, _ = await _safe_json(
|
||||
session,
|
||||
f"{base}/api/v4/projects/{project}/repository/tags?per_page={int(limit)}",
|
||||
headers=headers,
|
||||
)
|
||||
if isinstance(data, list):
|
||||
for t in data:
|
||||
if isinstance(t, dict) and t.get("name"):
|
||||
_add(str(t["name"]), str(t["name"]), "tag")
|
||||
|
||||
return out
|
||||
|
||||
# gitea (incl. Bahmcloud)
|
||||
u = urlparse(repo_url.rstrip("/"))
|
||||
base = f"{u.scheme}://{u.netloc}"
|
||||
|
||||
data, _ = await _safe_json(
|
||||
session,
|
||||
f"{base}/api/v1/repos/{owner}/{repo}/releases?limit={int(limit)}",
|
||||
headers=headers,
|
||||
)
|
||||
if isinstance(data, list):
|
||||
for r in data:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
tag = r.get("tag_name")
|
||||
name = r.get("name")
|
||||
if tag:
|
||||
lbl = str(tag)
|
||||
if isinstance(name, str) and name.strip() and name.strip() != str(tag):
|
||||
lbl = f"{tag} — {name.strip()}"
|
||||
_add(str(tag), lbl, "release")
|
||||
|
||||
data, _ = await _safe_json(
|
||||
session,
|
||||
f"{base}/api/v1/repos/{owner}/{repo}/tags?limit={int(limit)}",
|
||||
headers=headers,
|
||||
)
|
||||
if isinstance(data, list):
|
||||
for t in data:
|
||||
if isinstance(t, dict) and t.get("name"):
|
||||
_add(str(t["name"]), str(t["name"]), "tag")
|
||||
|
||||
return out
|
||||
|
||||
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
|
||||
|
||||
55
custom_components/bahmcloud_store/repairs.py
Normal file
55
custom_components/bahmcloud_store/repairs.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant import data_entry_flow
|
||||
|
||||
from .core import RESTART_REQUIRED_ISSUE_ID
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BCSRestartRequiredFlow(RepairsFlow):
|
||||
"""Repairs flow to restart Home Assistant after BCS install/update."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
self.hass = hass
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
return await self.async_step_confirm(user_input)
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
if user_input is not None:
|
||||
_LOGGER.info("BCS repairs flow: restarting Home Assistant (user confirmed)")
|
||||
await self.hass.services.async_call(
|
||||
"homeassistant",
|
||||
"restart",
|
||||
{},
|
||||
blocking=False,
|
||||
)
|
||||
return self.async_create_entry(title="", data={})
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="confirm",
|
||||
data_schema=vol.Schema({}),
|
||||
)
|
||||
|
||||
|
||||
async def async_create_fix_flow(
|
||||
hass: HomeAssistant,
|
||||
issue_id: str,
|
||||
data: dict[str, str | int | float | None] | None,
|
||||
) -> RepairsFlow:
|
||||
"""Create a repairs flow for BCS fixable issues."""
|
||||
if issue_id == RESTART_REQUIRED_ISSUE_ID:
|
||||
return BCSRestartRequiredFlow(hass)
|
||||
|
||||
raise data_entry_flow.UnknownHandler
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
@@ -18,21 +19,131 @@ class CustomRepo:
|
||||
name: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstalledRepo:
|
||||
repo_id: str
|
||||
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
|
||||
|
||||
|
||||
class BCSStorage:
|
||||
"""Persistent storage for manually added repositories."""
|
||||
"""Persistent storage for Bahmcloud Store.
|
||||
|
||||
Keys:
|
||||
- custom_repos: list of manually added repositories
|
||||
- installed_repos: mapping repo_id -> installed metadata
|
||||
- settings: persistent user settings (e.g. toggles in the UI)
|
||||
- hacs_cache: cached HACS metadata to improve UX (display names/descriptions)
|
||||
- repo_cache: cached per-repo enrichment (names/descriptions/versions) to keep the UI populated after restart
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
self.hass = hass
|
||||
self._store = Store(hass, _STORAGE_VERSION, _STORAGE_KEY)
|
||||
self._store: Store[dict[str, Any]] = Store(hass, _STORAGE_VERSION, _STORAGE_KEY)
|
||||
|
||||
async def _load(self) -> dict[str, Any]:
|
||||
data = await self._store.async_load()
|
||||
if not data:
|
||||
return {"custom_repos": []}
|
||||
if "custom_repos" not in data:
|
||||
data = await self._store.async_load() or {}
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
|
||||
if "custom_repos" not in data or not isinstance(data.get("custom_repos"), list):
|
||||
data["custom_repos"] = []
|
||||
|
||||
if "installed_repos" not in data or not isinstance(data.get("installed_repos"), dict):
|
||||
data["installed_repos"] = {}
|
||||
|
||||
if "settings" not in data or not isinstance(data.get("settings"), dict):
|
||||
data["settings"] = {}
|
||||
|
||||
if "hacs_cache" not in data or not isinstance(data.get("hacs_cache"), dict):
|
||||
data["hacs_cache"] = {}
|
||||
|
||||
if "repo_cache" not in data or not isinstance(data.get("repo_cache"), dict):
|
||||
data["repo_cache"] = {}
|
||||
|
||||
return data
|
||||
|
||||
async def get_repo_cache(self) -> dict[str, Any]:
|
||||
"""Return cached per-repo enrichment data.
|
||||
|
||||
Shape:
|
||||
{
|
||||
"fetched_at": <unix_ts>,
|
||||
"repos": {
|
||||
"<repo_id>": {
|
||||
"ts": <unix_ts>,
|
||||
"url": "...",
|
||||
"name": "...",
|
||||
"provider_description": "...",
|
||||
"meta_name": "...",
|
||||
"meta_description": "...",
|
||||
"meta_category": "...",
|
||||
"meta_source": "...",
|
||||
"latest_version": "...",
|
||||
"latest_version_source": "...",
|
||||
"default_branch": "...",
|
||||
"owner": "...",
|
||||
"provider_repo_name": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
data = await self._load()
|
||||
cache = data.get("repo_cache", {})
|
||||
return cache if isinstance(cache, dict) else {}
|
||||
|
||||
async def set_repo_cache(self, cache: dict[str, Any]) -> None:
|
||||
"""Persist cached per-repo enrichment data."""
|
||||
data = await self._load()
|
||||
data["repo_cache"] = cache if isinstance(cache, dict) else {}
|
||||
await self._save(data)
|
||||
|
||||
async def get_hacs_cache(self) -> dict[str, Any]:
|
||||
"""Return cached HACS metadata.
|
||||
|
||||
Shape:
|
||||
{
|
||||
"fetched_at": <unix_ts>,
|
||||
"repos": {"owner/repo": {"name": "...", "description": "...", "domain": "..."}}
|
||||
}
|
||||
"""
|
||||
data = await self._load()
|
||||
cache = data.get("hacs_cache", {})
|
||||
return cache if isinstance(cache, dict) else {}
|
||||
|
||||
async def set_hacs_cache(self, cache: dict[str, Any]) -> None:
|
||||
"""Persist cached HACS metadata."""
|
||||
data = await self._load()
|
||||
data["hacs_cache"] = cache if isinstance(cache, dict) else {}
|
||||
await self._save(data)
|
||||
|
||||
async def get_settings(self) -> dict[str, Any]:
|
||||
"""Return persistent settings.
|
||||
|
||||
Currently used for UI/behavior toggles.
|
||||
"""
|
||||
data = await self._load()
|
||||
settings = data.get("settings", {})
|
||||
return settings if isinstance(settings, dict) else {}
|
||||
|
||||
async def set_settings(self, updates: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Update persistent settings and return the merged settings."""
|
||||
data = await self._load()
|
||||
settings = data.get("settings", {})
|
||||
if not isinstance(settings, dict):
|
||||
settings = {}
|
||||
for k, v in (updates or {}).items():
|
||||
settings[str(k)] = v
|
||||
data["settings"] = settings
|
||||
await self._save(data)
|
||||
return settings
|
||||
|
||||
async def _save(self, data: dict[str, Any]) -> None:
|
||||
await self._store.async_save(data)
|
||||
|
||||
@@ -43,24 +154,20 @@ class BCSStorage:
|
||||
for r in repos:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
rid = str(r.get("id") or "")
|
||||
url = str(r.get("url") or "")
|
||||
name = r.get("name")
|
||||
if rid and url:
|
||||
out.append(CustomRepo(id=rid, url=url, name=str(name) if name else None))
|
||||
rid = r.get("id")
|
||||
url = r.get("url")
|
||||
if not rid or not url:
|
||||
continue
|
||||
out.append(CustomRepo(id=str(rid), url=str(url), name=r.get("name")))
|
||||
return out
|
||||
|
||||
async def add_custom_repo(self, url: str, name: str | None) -> CustomRepo:
|
||||
data = await self._load()
|
||||
repos = data.get("custom_repos", [])
|
||||
|
||||
# Deduplicate by URL
|
||||
# De-duplicate by URL
|
||||
for r in repos:
|
||||
if isinstance(r, dict) and str(r.get("url", "")).strip() == url.strip():
|
||||
# Update name if provided
|
||||
if name:
|
||||
r["name"] = name
|
||||
await self._save(data)
|
||||
if isinstance(r, dict) and str(r.get("url") or "").strip() == url.strip():
|
||||
return CustomRepo(id=str(r["id"]), url=str(r["url"]), name=r.get("name"))
|
||||
|
||||
rid = f"custom:{uuid.uuid4().hex[:10]}"
|
||||
@@ -73,6 +180,105 @@ class BCSStorage:
|
||||
async def remove_custom_repo(self, repo_id: str) -> None:
|
||||
data = await self._load()
|
||||
repos = data.get("custom_repos", [])
|
||||
data["custom_repos"] = [r for r in repos if not (isinstance(r, dict) and r.get("id") == repo_id)]
|
||||
data["custom_repos"] = [
|
||||
r for r in repos if not (isinstance(r, dict) and r.get("id") == repo_id)
|
||||
]
|
||||
await self._save(data)
|
||||
|
||||
async def get_installed_repo(self, repo_id: str) -> InstalledRepo | None:
|
||||
data = await self._load()
|
||||
installed = data.get("installed_repos", {})
|
||||
if not isinstance(installed, dict):
|
||||
return None
|
||||
entry = installed.get(repo_id)
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
|
||||
try:
|
||||
domains = entry.get("domains") or []
|
||||
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")
|
||||
|
||||
# Backward compatibility:
|
||||
# If installed_version wasn't stored, fall back to ref.
|
||||
if (not installed_version) and ref:
|
||||
installed_version = ref
|
||||
|
||||
installed_manifest_version = entry.get("installed_manifest_version")
|
||||
|
||||
return InstalledRepo(
|
||||
repo_id=str(entry.get("repo_id") or repo_id),
|
||||
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,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def list_installed_repos(self) -> list[InstalledRepo]:
|
||||
data = await self._load()
|
||||
installed = data.get("installed_repos", {})
|
||||
out: list[InstalledRepo] = []
|
||||
if not isinstance(installed, dict):
|
||||
return out
|
||||
for rid in list(installed.keys()):
|
||||
item = await self.get_installed_repo(str(rid))
|
||||
if item:
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
async def set_installed_repo(
|
||||
self,
|
||||
*,
|
||||
repo_id: str,
|
||||
url: str,
|
||||
domains: list[str],
|
||||
installed_version: str | None = None,
|
||||
installed_manifest_version: str | None = 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", {})
|
||||
if not isinstance(installed, dict):
|
||||
installed = {}
|
||||
data["installed_repos"] = installed
|
||||
|
||||
installed[str(repo_id)] = {
|
||||
"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,
|
||||
# informational only
|
||||
"installed_manifest_version": installed_manifest_version,
|
||||
# keep ref too (debug/backward compatibility)
|
||||
"ref": ref,
|
||||
}
|
||||
await self._save(data)
|
||||
|
||||
async def remove_installed_repo(self, repo_id: str) -> None:
|
||||
data = await self._load()
|
||||
installed = data.get("installed_repos", {})
|
||||
if isinstance(installed, dict) and repo_id in installed:
|
||||
installed.pop(repo_id, None)
|
||||
data["installed_repos"] = installed
|
||||
await self._save(data)
|
||||
|
||||
43
custom_components/bahmcloud_store/strings.json
Normal file
43
custom_components/bahmcloud_store/strings.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"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",
|
||||
"description": "One or more integrations were installed or updated by Bahmcloud Store. Restart Home Assistant to load the changes."
|
||||
}
|
||||
},
|
||||
"repair_flow": {
|
||||
"restart_required": {
|
||||
"step": {
|
||||
"confirm": {
|
||||
"title": "Restart Home Assistant",
|
||||
"description": "Bahmcloud Store installed or updated integrations. Restart Home Assistant now to apply the changes."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,152 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# NOTE:
|
||||
# Update entities will be implemented once installation/provider resolution is in place.
|
||||
# This stub prevents platform load errors and keeps the integration stable in 0.3.0.
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
|
||||
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 .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:
|
||||
repo = core.get_repo(repo_id)
|
||||
if repo and getattr(repo, "name", None):
|
||||
name = str(repo.name).strip()
|
||||
if name:
|
||||
return name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if repo_id.startswith("index:"):
|
||||
return f"BCS Index {repo_id.split(':', 1)[1]}"
|
||||
if repo_id.startswith("custom:"):
|
||||
return f"BCS Custom {repo_id.split(':', 1)[1]}"
|
||||
return f"BCS {repo_id}"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _RepoKey:
|
||||
repo_id: str
|
||||
|
||||
|
||||
class BCSRepoUpdateEntity(UpdateEntity):
|
||||
"""Update entity representing a BCS-managed repository."""
|
||||
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_supported_features = UpdateEntityFeature.INSTALL
|
||||
|
||||
def __init__(self, core: BCSCore, repo_id: str) -> None:
|
||||
self._core = core
|
||||
self._repo_id = repo_id
|
||||
self._in_progress = False
|
||||
|
||||
# Stable unique id (do NOT change)
|
||||
self._attr_unique_id = f"{DOMAIN}:{repo_id}"
|
||||
|
||||
self._refresh_display_name()
|
||||
|
||||
def _refresh_display_name(self) -> None:
|
||||
pretty = _pretty_repo_name(self._core, self._repo_id)
|
||||
self._attr_name = pretty
|
||||
self._attr_title = pretty
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
repo = self._core.get_repo(self._repo_id)
|
||||
installed = self._core.get_installed(self._repo_id)
|
||||
return repo is not None and installed is not None
|
||||
|
||||
@property
|
||||
def in_progress(self) -> bool | None:
|
||||
return self._in_progress
|
||||
|
||||
@property
|
||||
def installed_version(self) -> str | None:
|
||||
installed = self._core.get_installed(self._repo_id) or {}
|
||||
v = installed.get("installed_version") or installed.get("ref")
|
||||
return str(v) if v else None
|
||||
|
||||
@property
|
||||
def latest_version(self) -> str | None:
|
||||
repo = self._core.get_repo(self._repo_id)
|
||||
if not repo:
|
||||
return None
|
||||
v = getattr(repo, "latest_version", None)
|
||||
return str(v) if v else None
|
||||
|
||||
@property
|
||||
def update_available(self) -> bool:
|
||||
latest = self.latest_version
|
||||
installed = self.installed_version
|
||||
if not latest or not installed:
|
||||
return False
|
||||
return latest != installed
|
||||
|
||||
def version_is_newer(self, latest_version: str, installed_version: str) -> bool:
|
||||
return latest_version != installed_version
|
||||
|
||||
@property
|
||||
def release_url(self) -> str | None:
|
||||
repo = self._core.get_repo(self._repo_id)
|
||||
return getattr(repo, "url", None) if repo else None
|
||||
|
||||
async def async_install(self, version: str | None, backup: bool, **kwargs: Any) -> None:
|
||||
if version is not None:
|
||||
_LOGGER.debug("BCS update entity requested specific version=%s (ignored)", version)
|
||||
|
||||
self._in_progress = True
|
||||
self.async_write_ha_state()
|
||||
|
||||
try:
|
||||
await self._core.update_repo(self._repo_id)
|
||||
finally:
|
||||
self._in_progress = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
@callback
|
||||
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 {}
|
||||
new_entities: list[BCSRepoUpdateEntity] = []
|
||||
|
||||
for repo_id, data in installed_map.items():
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
|
||||
if repo_id in existing:
|
||||
# IMPORTANT: Update display name after refresh, when repo.name becomes available.
|
||||
existing[repo_id]._refresh_display_name()
|
||||
continue
|
||||
|
||||
ent = BCSRepoUpdateEntity(core, repo_id)
|
||||
existing[repo_id] = ent
|
||||
new_entities.append(ent)
|
||||
|
||||
if new_entities:
|
||||
async_add_entities(new_entities)
|
||||
|
||||
for ent in existing.values():
|
||||
ent.async_write_ha_state()
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
@@ -14,4 +155,27 @@ async def async_setup_platform(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
discovery_info=None,
|
||||
):
|
||||
"""Set up BCS update entities."""
|
||||
core: BCSCore | None = _get_core(hass)
|
||||
if not core:
|
||||
_LOGGER.debug("BCS core not available, skipping update platform setup")
|
||||
return
|
||||
|
||||
entities: dict[str, BCSRepoUpdateEntity] = {}
|
||||
|
||||
_sync_entities(core, entities, async_add_entities)
|
||||
|
||||
@callback
|
||||
def _handle_update() -> None:
|
||||
_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)
|
||||
@@ -16,14 +16,12 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _render_markdown_server_side(md: str) -> str | None:
|
||||
"""Render Markdown -> sanitized HTML (server-side)."""
|
||||
text = (md or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
|
||||
html: str | None = None
|
||||
|
||||
# 1) python-markdown
|
||||
try:
|
||||
import markdown as mdlib # type: ignore
|
||||
|
||||
@@ -39,7 +37,6 @@ def _render_markdown_server_side(md: str) -> str | None:
|
||||
if not html:
|
||||
return None
|
||||
|
||||
# 2) Sanitize via bleach
|
||||
try:
|
||||
import bleach # type: ignore
|
||||
|
||||
@@ -124,16 +121,6 @@ def _maybe_decode_base64(content: str, encoding: Any) -> str | None:
|
||||
|
||||
|
||||
def _extract_text_recursive(obj: Any, depth: int = 0) -> str | None:
|
||||
"""
|
||||
Robust extraction for README markdown.
|
||||
|
||||
Handles:
|
||||
- str / bytes
|
||||
- dict with:
|
||||
- {content: "...", encoding: "base64"} (possibly nested)
|
||||
- {readme: "..."} etc.
|
||||
- list of dicts (pick first matching)
|
||||
"""
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
@@ -150,21 +137,16 @@ def _extract_text_recursive(obj: Any, depth: int = 0) -> str | None:
|
||||
return None
|
||||
|
||||
if isinstance(obj, dict):
|
||||
# 1) If it looks like "file content"
|
||||
content = obj.get("content")
|
||||
encoding = obj.get("encoding")
|
||||
|
||||
# Base64 decode if possible
|
||||
decoded = _maybe_decode_base64(content, encoding)
|
||||
if decoded:
|
||||
return decoded
|
||||
|
||||
# content may already be plain text
|
||||
if isinstance(content, str) and (not isinstance(encoding, str) or not encoding.strip()):
|
||||
# Heuristic: treat as markdown if it has typical markdown chars, otherwise still return
|
||||
return content
|
||||
|
||||
# 2) direct text keys (readme/markdown/text/body/data)
|
||||
for k in _TEXT_KEYS:
|
||||
v = obj.get(k)
|
||||
if isinstance(v, str):
|
||||
@@ -175,7 +157,6 @@ def _extract_text_recursive(obj: Any, depth: int = 0) -> str | None:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3) Sometimes nested under "file" / "result" / "payload" etc.
|
||||
for v in obj.values():
|
||||
out = _extract_text_recursive(v, depth + 1)
|
||||
if out:
|
||||
@@ -198,7 +179,7 @@ class StaticAssetsView(HomeAssistantView):
|
||||
name = "api:bahmcloud_store_static"
|
||||
requires_auth = False
|
||||
|
||||
async def get(self, request: web.Request, path: str) -> web.Response:
|
||||
async def get(self, request: web.Request, path: str) -> web.StreamResponse:
|
||||
base = Path(__file__).resolve().parent / "panel"
|
||||
base_resolved = base.resolve()
|
||||
|
||||
@@ -218,24 +199,7 @@ class StaticAssetsView(HomeAssistantView):
|
||||
_LOGGER.error("BCS static asset not found: %s", target)
|
||||
return web.Response(status=404)
|
||||
|
||||
content_type = "text/plain"
|
||||
charset = None
|
||||
|
||||
if target.suffix == ".js":
|
||||
content_type = "application/javascript"
|
||||
charset = "utf-8"
|
||||
elif target.suffix == ".html":
|
||||
content_type = "text/html"
|
||||
charset = "utf-8"
|
||||
elif target.suffix == ".css":
|
||||
content_type = "text/css"
|
||||
charset = "utf-8"
|
||||
elif target.suffix == ".svg":
|
||||
content_type = "image/svg+xml"
|
||||
elif target.suffix == ".png":
|
||||
content_type = "image/png"
|
||||
|
||||
resp = web.Response(body=target.read_bytes(), content_type=content_type, charset=charset)
|
||||
resp = web.FileResponse(path=target)
|
||||
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
resp.headers["Pragma"] = "no-cache"
|
||||
return resp
|
||||
@@ -247,15 +211,34 @@ class BCSApiView(HomeAssistantView):
|
||||
requires_auth = True
|
||||
|
||||
def __init__(self, core: Any) -> None:
|
||||
self.core = core
|
||||
self.core: BCSCore = core
|
||||
|
||||
async def get(self, request: web.Request) -> web.Response:
|
||||
return web.json_response(
|
||||
{"ok": True, "version": self.core.version, "repos": self.core.list_repos_public()}
|
||||
{
|
||||
"ok": True,
|
||||
"version": self.core.version,
|
||||
"settings": self.core.get_settings_public(),
|
||||
"repos": self.core.list_repos_public(),
|
||||
}
|
||||
)
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
action = request.query.get("action")
|
||||
if action == "refresh":
|
||||
_LOGGER.info("BCS manual refresh triggered via API")
|
||||
try:
|
||||
await self.core.full_refresh(source="manual")
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
_LOGGER.error("BCS manual refresh failed: %s", e)
|
||||
return web.json_response({"ok": False, "message": "Refresh failed"}, status=500)
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
|
||||
op = data.get("op")
|
||||
|
||||
if op == "add_custom_repo":
|
||||
@@ -270,13 +253,47 @@ class BCSApiView(HomeAssistantView):
|
||||
return web.json_response({"ok": False, "message": "Unknown operation"}, status=400)
|
||||
|
||||
|
||||
class BCSSettingsView(HomeAssistantView):
|
||||
"""Persistent UI settings (e.g. toggles)."""
|
||||
|
||||
url = "/api/bcs/settings"
|
||||
name = "api:bcs_settings"
|
||||
requires_auth = True
|
||||
|
||||
def __init__(self, core: Any) -> None:
|
||||
self.core: BCSCore = core
|
||||
|
||||
async def get(self, request: web.Request) -> web.Response:
|
||||
return web.json_response({"ok": True, "settings": self.core.get_settings_public()})
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
|
||||
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)
|
||||
return web.json_response({"ok": True, "settings": settings})
|
||||
except Exception as e:
|
||||
_LOGGER.exception("BCS set settings failed: %s", e)
|
||||
return web.json_response({"ok": False, "message": str(e) or "Failed"}, status=500)
|
||||
|
||||
|
||||
class BCSCustomRepoView(HomeAssistantView):
|
||||
url = "/api/bcs/custom_repo"
|
||||
name = "api:bcs_custom_repo"
|
||||
requires_auth = True
|
||||
|
||||
def __init__(self, core: Any) -> None:
|
||||
self.core = core
|
||||
self.core: BCSCore = core
|
||||
|
||||
async def delete(self, request: web.Request) -> web.Response:
|
||||
repo_id = request.query.get("id")
|
||||
@@ -292,7 +309,7 @@ class BCSReadmeView(HomeAssistantView):
|
||||
requires_auth = True
|
||||
|
||||
def __init__(self, core: Any) -> None:
|
||||
self.core = core
|
||||
self.core: BCSCore = core
|
||||
|
||||
async def get(self, request: web.Request) -> web.Response:
|
||||
repo_id = request.query.get("repo_id")
|
||||
@@ -309,8 +326,253 @@ class BCSReadmeView(HomeAssistantView):
|
||||
status=404,
|
||||
)
|
||||
|
||||
# Ensure strict JSON string output (avoid accidental objects)
|
||||
md_str = str(md)
|
||||
|
||||
html = _render_markdown_server_side(md_str)
|
||||
return web.json_response({"ok": True, "readme": md_str, "html": html})
|
||||
|
||||
|
||||
class BCSVersionsView(HomeAssistantView):
|
||||
url = "/api/bcs/versions"
|
||||
name = "api:bcs_versions"
|
||||
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)
|
||||
|
||||
try:
|
||||
versions = await self.core.list_repo_versions(repo_id)
|
||||
return web.json_response({"ok": True, "repo_id": repo_id, "versions": versions}, status=200)
|
||||
except Exception as e:
|
||||
_LOGGER.exception("BCS list versions failed: %s", e)
|
||||
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"
|
||||
requires_auth = True
|
||||
|
||||
def __init__(self, core: Any) -> None:
|
||||
self.core: BCSCore = core
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
repo_id = request.query.get("repo_id")
|
||||
version = request.query.get("version")
|
||||
if not repo_id:
|
||||
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
|
||||
|
||||
try:
|
||||
v = str(version).strip() if version is not None else None
|
||||
result = await self.core.install_repo(repo_id, version=v)
|
||||
return web.json_response(result, status=200)
|
||||
except Exception as e:
|
||||
_LOGGER.exception("BCS install failed: %s", e)
|
||||
return web.json_response({"ok": False, "message": str(e) or "Install failed"}, status=500)
|
||||
|
||||
|
||||
class BCSUpdateView(HomeAssistantView):
|
||||
url = "/api/bcs/update"
|
||||
name = "api:bcs_update"
|
||||
requires_auth = True
|
||||
|
||||
def __init__(self, core: Any) -> None:
|
||||
self.core: BCSCore = core
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
repo_id = request.query.get("repo_id")
|
||||
version = request.query.get("version")
|
||||
if not repo_id:
|
||||
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
|
||||
|
||||
try:
|
||||
v = str(version).strip() if version is not None else None
|
||||
result = await self.core.update_repo(repo_id, version=v)
|
||||
return web.json_response(result, status=200)
|
||||
except Exception as e:
|
||||
_LOGGER.exception("BCS update failed: %s", e)
|
||||
return web.json_response({"ok": False, "message": str(e) or "Update failed"}, status=500)
|
||||
|
||||
|
||||
class BCSUninstallView(HomeAssistantView):
|
||||
url = "/api/bcs/uninstall"
|
||||
name = "api:bcs_uninstall"
|
||||
requires_auth = True
|
||||
|
||||
def __init__(self, core: Any) -> None:
|
||||
self.core: BCSCore = core
|
||||
|
||||
async def post(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)
|
||||
|
||||
try:
|
||||
result = await self.core.uninstall_repo(repo_id)
|
||||
return web.json_response(result, status=200)
|
||||
except Exception as e:
|
||||
_LOGGER.exception("BCS uninstall failed: %s", e)
|
||||
return web.json_response({"ok": False, "message": str(e) or "Uninstall failed"}, status=500)
|
||||
|
||||
|
||||
|
||||
|
||||
class BCSBackupsView(HomeAssistantView):
|
||||
url = "/api/bcs/backups"
|
||||
name = "api:bcs_backups"
|
||||
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)
|
||||
|
||||
try:
|
||||
backups = await self.core.list_repo_backups(repo_id)
|
||||
return web.json_response({"ok": True, "repo_id": repo_id, "backups": backups}, status=200)
|
||||
except Exception as e:
|
||||
_LOGGER.exception("BCS list backups failed: %s", e)
|
||||
return web.json_response({"ok": False, "message": str(e) or "List backups failed"}, status=500)
|
||||
|
||||
|
||||
class BCSRestoreView(HomeAssistantView):
|
||||
url = "/api/bcs/restore"
|
||||
name = "api:bcs_restore"
|
||||
requires_auth = True
|
||||
|
||||
def __init__(self, core: Any) -> None:
|
||||
self.core: BCSCore = core
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
repo_id = request.query.get("repo_id")
|
||||
backup_id = request.query.get("backup_id")
|
||||
if not repo_id:
|
||||
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
|
||||
if not backup_id:
|
||||
return web.json_response({"ok": False, "message": "Missing backup_id"}, status=400)
|
||||
|
||||
try:
|
||||
result = await self.core.restore_repo_backup(repo_id, backup_id)
|
||||
return web.json_response(result, status=200)
|
||||
except Exception as e:
|
||||
_LOGGER.exception("BCS restore failed: %s", e)
|
||||
return web.json_response({"ok": False, "message": str(e) or "Restore failed"}, status=500)
|
||||
|
||||
|
||||
class BCSRestartView(HomeAssistantView):
|
||||
url = "/api/bcs/restart"
|
||||
name = "api:bcs_restart"
|
||||
requires_auth = True
|
||||
|
||||
def __init__(self, core: Any) -> None:
|
||||
self.core: BCSCore = core
|
||||
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self.core.request_restart()
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
_LOGGER.exception("BCS restart failed: %s", e)
|
||||
return web.json_response({"ok": False, "message": str(e) or "Restart failed"}, status=500)
|
||||
|
||||
class BCSRepoDetailView(HomeAssistantView):
|
||||
url = "/api/bcs/repo"
|
||||
name = "api:bcs_repo"
|
||||
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") or "").strip()
|
||||
if not repo_id:
|
||||
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
|
||||
|
||||
try:
|
||||
repo = await self.core.ensure_repo_details(repo_id)
|
||||
if not repo:
|
||||
return web.json_response({"ok": False, "message": "Repo not found"}, status=404)
|
||||
|
||||
inst = self.core.get_installed(repo_id) or {}
|
||||
installed = bool(inst)
|
||||
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,
|
||||
"repo": {
|
||||
"id": repo.id,
|
||||
"name": repo.name,
|
||||
"url": repo.url,
|
||||
"source": repo.source,
|
||||
"owner": repo.owner,
|
||||
"provider": repo.provider,
|
||||
"repo_name": repo.provider_repo_name,
|
||||
"description": repo.provider_description or repo.meta_description,
|
||||
"default_branch": repo.default_branch,
|
||||
"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:
|
||||
_LOGGER.exception("BCS repo details failed: %s", e)
|
||||
return web.json_response({"ok": False, "message": str(e) or "Repo details failed"}, status=500)
|
||||
|
||||
Reference in New Issue
Block a user