Compare commits
133 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 599b8e0b7d | |||
| 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 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -174,3 +174,6 @@ cython_debug/
|
|||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
|
# Local project instructions
|
||||||
|
.idea/start prompt.md
|
||||||
|
|
||||||
|
|||||||
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>
|
||||||
57
.idea/changes.md
generated
Normal file
57
.idea/changes.md
generated
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Changes Log
|
||||||
|
|
||||||
|
## 2026-03-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Created `.idea/start prompt.md` as a persistent project working prompt for future sessions.
|
||||||
|
- Added `.idea/changes.md` as the preferred in-project location for the detailed session change log.
|
||||||
|
- Added release notes support in the active Bahmcloud Store runtime path: backend provider fetching, a dedicated API endpoint, and panel display for the currently selected version when provider release notes are available.
|
||||||
|
- Bumped the Home Assistant panel asset cache-buster from `v=109` to `v=110` so the updated frontend loads reliably after deployment.
|
||||||
|
- Bumped the integration version from `0.7.2` to `0.7.3`.
|
||||||
|
- Added the `0.7.3` release entry to `CHANGELOG.md` with the new release-notes and project-workflow changes.
|
||||||
|
- Added a persistent rule that commit messages, pushes, release notes, and changelog entries must never mention prompts, AI/KI tools, or `.idea` folder files.
|
||||||
|
- Added a persistent release rule that future git tags and release names must use plain version numbers without a leading `v`.
|
||||||
|
- Added a broader persistent rule that internal workflow or prompt/process decisions must never appear in any public-facing project communication.
|
||||||
|
- Added a persistent language rule that all project-facing repository content must stay in English regardless of the chat language.
|
||||||
|
- Added persistent pinned repositories support: favorites are stored in settings, exposed by the backend, filterable and sortable in the store view, and toggleable from the detail view without forcing a full repository refresh.
|
||||||
|
- Bumped the integration version from `0.7.3` to `0.7.4` and added the `0.7.4` release entry to `CHANGELOG.md` for the pinned-repositories feature.
|
||||||
|
- Added the broader product roadmap to the persistent project prompt: blueprints, templates, Lovelace designs, and more category support as future store targets.
|
||||||
|
- Started the blueprint/category-aware installer preparation: installation metadata now distinguishes install type and installed paths, repo payloads expose install targets, and the active panel shows install-target context for future non-integration categories.
|
||||||
|
- Added initial blueprint install-path handling groundwork so the codebase is no longer fully hard-wired to `custom_components/`.
|
||||||
|
- Bumped the integration version from `0.7.4` to `0.7.5` and added the `0.7.5` release entry to `CHANGELOG.md` for blueprint support and the documentation refresh.
|
||||||
|
- Fixed blueprint update backups: blueprint file updates now create content backups before overwrite and can roll back copied blueprint files if installation fails.
|
||||||
|
- Kept the no-restart behavior for blueprints, because blueprint deployment does not normally require a Home Assistant restart.
|
||||||
|
- Restored blueprint backup/restore availability in the UI and backend: the restore button is visible again for blueprint installs, blueprint backups can be listed, and blueprint content can now be restored from backup without forcing a restart.
|
||||||
|
- Fixed category-specific uninstall and restore messaging in the active panel: blueprints now reference `/config/blueprints` and explicitly state that no restart is required, while integrations keep the `/config/custom_components` restart warning.
|
||||||
|
- Updated the public documentation set (`README.md`, `README_DEVELOPER.md`, `README_FULL.md`, and `CHANGELOG.md`) for the completed blueprint backup, restore, uninstall, and restart-behavior work, and bumped the integration version to `0.7.6` for the next release.
|
||||||
|
- Fixed stale Home Assistant update entities after uninstall: BCS now removes orphaned update entities from the running platform state and entity registry when an installed repository or blueprint is uninstalled.
|
||||||
|
|
||||||
|
### Documented
|
||||||
|
- Captured the verified project identity from the repository and README files: Bahmcloud Store is a Home Assistant custom integration intended to behave like a provider-neutral store for custom integrations, similar to HACS but broader than GitHub-only workflows.
|
||||||
|
- Recorded the actual active architecture from source analysis, including the config-entry-only setup, the fixed Bahmcloud store index, delayed startup refresh, periodic refresh, repo merge flow, cache usage, install/update/uninstall pipeline, backup/restore pipeline, update entities, and Repairs-based restart handling.
|
||||||
|
- Recorded the current provider reality from code: GitHub, GitLab, and Gitea-compatible repositories are the concrete supported paths today, while truly generic "all git providers" support is still an intention and must be validated case by case.
|
||||||
|
- Recorded the public API endpoints exposed by `views.py` so future work preserves the current backend contract unless a deliberate breaking change is approved.
|
||||||
|
- Recorded storage facts from `storage.py`, including the `bcs_store` Home Assistant storage key and the persisted sections for custom repositories, installed repositories, settings, HACS cache, and repo enrichment cache.
|
||||||
|
- Recorded frontend facts from the active panel registration in `__init__.py` and the active frontend implementation in `panel/panel.js`, including the cache-busting panel asset version query.
|
||||||
|
- Updated the persistent start prompt to point future work to `.idea/changes.md` as the canonical detailed work log.
|
||||||
|
- Release notes are intentionally tied to provider release objects, so tags or branches without release bodies now return a clear "not available" state instead of misleading fallback text.
|
||||||
|
|
||||||
|
### Important findings from code analysis
|
||||||
|
- Identified `custom_components/bahmcloud_store/panel/panel.js` as the active Home Assistant panel script currently loaded by the integration.
|
||||||
|
- Identified `custom_components/bahmcloud_store/panel/app.js`, `custom_components/bahmcloud_store/panel/index.html`, and `custom_components/bahmcloud_store/panel/styles.css` as likely legacy or secondary assets that should not be treated as authoritative without verification.
|
||||||
|
- Identified `custom_components/bahmcloud_store/store.py` as an older implementation with a different data model and API shape than the active `BCSCore` runtime.
|
||||||
|
- Identified `custom_components/bahmcloud_store/custom_repo_view.py` as duplicate or legacy API code because the active custom-repo handling already exists in `views.py`.
|
||||||
|
- Noted that the README set is directionally useful but not fully authoritative where it conflicts with current code behavior.
|
||||||
|
- Noted that some repository files contain encoding or mojibake artifacts, so future edits should preserve valid UTF-8 and avoid spreading broken text.
|
||||||
|
|
||||||
|
### Project rules written into the start prompt
|
||||||
|
- Never push, commit, tag, or create a release without explicit user approval.
|
||||||
|
- Always append a dated and detailed entry to `.idea/changes.md` for every change made.
|
||||||
|
- When a release is created, collect all relevant changes since the last release into `CHANGELOG.md`.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
- Reviewed repository structure and current git status.
|
||||||
|
- Read `README.md`, `README_DEVELOPER.md`, `README_FULL.md`, `bcs.yaml`, and the current `CHANGELOG.md`.
|
||||||
|
- Analyzed the active backend files: `__init__.py`, `const.py`, `core.py`, `providers.py`, `metadata.py`, `storage.py`, `views.py`, `config_flow.py`, `update.py`, and `repairs.py`.
|
||||||
|
- Checked panel and legacy-related files to distinguish the currently active UI path from older or duplicated files.
|
||||||
|
- Verified that the active panel (`panel/panel.js`) now requests release notes from the new backend route and reloads them when the selected install version changes.
|
||||||
8
.idea/modules.xml
generated
Normal file
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>
|
||||||
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>
|
||||||
278
CHANGELOG.md
278
CHANGELOG.md
@@ -11,6 +11,284 @@ 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
|
## [0.5.3] - 2026-01-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
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.
|
||||||
2
bcs.yaml
2
bcs.yaml
@@ -4,7 +4,7 @@ description: >
|
|||||||
Supports GitHub, GitLab, Gitea and Bahmcloud repositories with
|
Supports GitHub, GitLab, Gitea and Bahmcloud repositories with
|
||||||
a central index, UI panel and API, similar to HACS but independent.
|
a central index, UI panel and API, similar to HACS but independent.
|
||||||
|
|
||||||
category: Store
|
category: Integrations
|
||||||
|
|
||||||
author: Bahmcloud
|
author: Bahmcloud
|
||||||
maintainer: Bahmcloud
|
maintainer: Bahmcloud
|
||||||
|
|||||||
@@ -1,166 +1,135 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from datetime import timedelta
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
from homeassistant.components.panel_custom import async_register_panel
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||||
from homeassistant.helpers.entity import EntityCategory
|
from homeassistant.helpers.event import async_call_later, async_track_time_interval
|
||||||
|
|
||||||
from .core import DOMAIN, SIGNAL_UPDATED, BCSCore
|
from .const import CONF_GITHUB_TOKEN, DEFAULT_STORE_URL, DOMAIN
|
||||||
|
from .core import BCSError, BCSConfig, BCSCore
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PLATFORMS: list[str] = ["update"]
|
||||||
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
|
|
||||||
|
|
||||||
# Fallbacks
|
|
||||||
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)
|
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
||||||
class _RepoKey:
|
"""Set up Bahmcloud Store.
|
||||||
repo_id: str
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
class BCSRepoUpdateEntity(UpdateEntity):
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Update entity representing a BCS-managed repository."""
|
"""Set up Bahmcloud Store from a config entry (UI setup)."""
|
||||||
|
# Only one instance.
|
||||||
|
hass.data.setdefault(DOMAIN, {})
|
||||||
|
|
||||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
github_token = (entry.options.get(CONF_GITHUB_TOKEN) or "").strip() or None
|
||||||
_attr_supported_features = UpdateEntityFeature.INSTALL
|
|
||||||
|
|
||||||
def __init__(self, core: BCSCore, repo_id: str) -> None:
|
core = BCSCore(
|
||||||
self._core = core
|
hass,
|
||||||
self._repo_id = repo_id
|
BCSConfig(
|
||||||
self._in_progress = False
|
store_url=DEFAULT_STORE_URL,
|
||||||
|
github_token=github_token,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# Stable unique id (do NOT change)
|
hass.data[DOMAIN][entry.entry_id] = core
|
||||||
self._attr_unique_id = f"{DOMAIN}:{repo_id}"
|
# Keep a convenient shortcut for platforms that previously used hass.data[DOMAIN] directly.
|
||||||
|
hass.data[DOMAIN]["_core"] = core
|
||||||
|
|
||||||
# Human-friendly name in UI
|
await core.async_initialize()
|
||||||
pretty = _pretty_repo_name(core, repo_id)
|
|
||||||
self._attr_name = pretty
|
|
||||||
|
|
||||||
# Title shown in the entity dialog
|
# HTTP views + panel (registered once per entry; we only allow one entry).
|
||||||
self._attr_title = pretty
|
from .views import (
|
||||||
|
StaticAssetsView,
|
||||||
|
BCSApiView,
|
||||||
|
BCSSettingsView,
|
||||||
|
BCSReadmeView,
|
||||||
|
BCSVersionsView,
|
||||||
|
BCSReleaseNotesView,
|
||||||
|
BCSRepoDetailView,
|
||||||
|
BCSCustomRepoView,
|
||||||
|
BCSInstallView,
|
||||||
|
BCSUpdateView,
|
||||||
|
BCSUninstallView,
|
||||||
|
BCSBackupsView,
|
||||||
|
BCSRestoreView,
|
||||||
|
BCSRestartView,
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
hass.http.register_view(StaticAssetsView())
|
||||||
def available(self) -> bool:
|
hass.http.register_view(BCSApiView(core))
|
||||||
repo = self._core.get_repo(self._repo_id)
|
hass.http.register_view(BCSSettingsView(core))
|
||||||
installed = self._core.get_installed(self._repo_id)
|
hass.http.register_view(BCSReadmeView(core))
|
||||||
return repo is not None and installed is not None
|
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))
|
||||||
|
|
||||||
@property
|
await async_register_panel(
|
||||||
def in_progress(self) -> bool | None:
|
hass,
|
||||||
return self._in_progress
|
frontend_url_path="bahmcloud-store",
|
||||||
|
webcomponent_name="bahmcloud-store-panel",
|
||||||
@property
|
# IMPORTANT: bump v to avoid caching old JS
|
||||||
def installed_version(self) -> str | None:
|
module_url="/api/bahmcloud_store_static/panel.js?v=112",
|
||||||
installed = self._core.get_installed(self._repo_id) or {}
|
sidebar_title="Bahmcloud Store",
|
||||||
v = installed.get("installed_version") or installed.get("ref")
|
sidebar_icon="mdi:store",
|
||||||
return str(v) if v else None
|
require_admin=True,
|
||||||
|
config={},
|
||||||
@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()
|
|
||||||
|
|
||||||
|
async def _do_startup_refresh(_now=None) -> None:
|
||||||
try:
|
try:
|
||||||
await self._core.update_repo(self._repo_id)
|
await core.full_refresh(source="startup")
|
||||||
finally:
|
except BCSError as e:
|
||||||
self._in_progress = False
|
_LOGGER.error("Initial refresh failed: %s", e)
|
||||||
self.async_write_ha_state()
|
|
||||||
|
# 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.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_seconds = int(getattr(core, "refresh_seconds", 300) or 300)
|
||||||
|
async_track_time_interval(hass, periodic, timedelta(seconds=interval_seconds))
|
||||||
|
|
||||||
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
@callback
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
def _sync_entities(
|
"""Unload a config entry."""
|
||||||
core: BCSCore,
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
existing: dict[str, BCSRepoUpdateEntity],
|
if unload_ok:
|
||||||
async_add_entities: AddEntitiesCallback,
|
try:
|
||||||
) -> None:
|
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
|
||||||
"""Ensure there is one update entity per installed repo."""
|
except Exception:
|
||||||
installed_map = getattr(core, "_installed_cache", {}) or {}
|
pass
|
||||||
new_entities: list[BCSRepoUpdateEntity] = []
|
return unload_ok
|
||||||
|
|
||||||
for repo_id, data in installed_map.items():
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
continue
|
|
||||||
if repo_id in existing:
|
|
||||||
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(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
config,
|
|
||||||
async_add_entities: AddEntitiesCallback,
|
|
||||||
discovery_info=None,
|
|
||||||
):
|
|
||||||
"""Set up BCS update entities."""
|
|
||||||
core: BCSCore | None = hass.data.get(DOMAIN)
|
|
||||||
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)
|
|
||||||
|
|||||||
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,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"domain": "bahmcloud_store",
|
"domain": "bahmcloud_store",
|
||||||
"name": "Bahmcloud Store",
|
"name": "Bahmcloud Store",
|
||||||
"version": "0.5.3",
|
"version": "0.7.6",
|
||||||
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
|
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
|
||||||
|
"config_flow": true,
|
||||||
"platforms": ["update"],
|
"platforms": ["update"],
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"codeowners": ["@bahmcloud"],
|
"codeowners": ["@bahmcloud"],
|
||||||
"iot_class": "local_polling"
|
"iot_class": "local_polling"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
|
|
||||||
this._installingRepoId = null;
|
this._installingRepoId = null;
|
||||||
this._updatingRepoId = null;
|
this._updatingRepoId = null;
|
||||||
|
this._uninstallingRepoId = null;
|
||||||
this._restartRequired = false;
|
this._restartRequired = false;
|
||||||
this._lastActionMsg = null;
|
this._lastActionMsg = null;
|
||||||
}
|
}
|
||||||
@@ -141,6 +142,36 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _uninstallRepo(repoId) {
|
||||||
|
if (!repoId) return;
|
||||||
|
if (this._installingRepoId || this._updatingRepoId || this._uninstallingRepoId) return;
|
||||||
|
|
||||||
|
const r = this._repoById(repoId);
|
||||||
|
const name = this._safeText(r?.name) || repoId;
|
||||||
|
|
||||||
|
const ok = window.confirm(`Uninstall "${name}"?\n\nThis will delete the integration folder(s) from /config/custom_components. A restart will be required.`);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
this._uninstallingRepoId = repoId;
|
||||||
|
this._lastActionMsg = null;
|
||||||
|
this.requestUpdate();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await this._hass.callApi("post", `bcs/uninstall?repo_id=${encodeURIComponent(repoId)}`, {});
|
||||||
|
if (resp && resp.ok) {
|
||||||
|
this._restartRequired = !!resp.restart_required;
|
||||||
|
this._lastActionMsg = "Uninstalled. Restart required.";
|
||||||
|
} else {
|
||||||
|
this._lastActionMsg = (resp && resp.message) ? String(resp.message) : "Uninstall failed.";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this._lastActionMsg = e && e.message ? e.message : "Uninstall failed.";
|
||||||
|
} finally {
|
||||||
|
this._uninstallingRepoId = null;
|
||||||
|
await this._load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async _restartHA() {
|
async _restartHA() {
|
||||||
if (!this._hass) return;
|
if (!this._hass) return;
|
||||||
try {
|
try {
|
||||||
@@ -436,15 +467,15 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
|
|
||||||
<div class="mobilebar">
|
<div class="mobilebar">
|
||||||
<div class="left">
|
<div class="left">
|
||||||
<div class="iconbtn" id="menuBtn" title="Menu">☰</div>
|
<div class="iconbtn" id="menuBtn" title="Menu">鈽<EFBFBD></div>
|
||||||
<div class="iconbtn" id="backBtn" title="Back">←</div>
|
<div class="iconbtn" id="backBtn" title="Back">鈫<EFBFBD></div>
|
||||||
<div>
|
<div>
|
||||||
<div style="font-weight:700;">Bahmcloud Store</div>
|
<div style="font-weight:700;">Bahmcloud Store</div>
|
||||||
<div class="muted small" id="subtitle">Store</div>
|
<div class="muted small" id="subtitle">Store</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="right">
|
<div class="right">
|
||||||
<div class="iconbtn" id="refreshBtn" title="Refresh">⟳</div>
|
<div class="iconbtn" id="refreshBtn" title="Refresh">鉄<EFBFBD></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -506,7 +537,7 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
: "";
|
: "";
|
||||||
|
|
||||||
if (this._loading) {
|
if (this._loading) {
|
||||||
content.innerHTML = `${err}<div class="card">Loading…</div>`;
|
content.innerHTML = `${err}<div class="card">Loading鈥<EFBFBD></div>`;
|
||||||
fabs.innerHTML = "";
|
fabs.innerHTML = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -608,7 +639,7 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
if (updateAvailable) badges.push("Update");
|
if (updateAvailable) badges.push("Update");
|
||||||
|
|
||||||
const badgeHtml = badges.length
|
const badgeHtml = badges.length
|
||||||
? `<div class="badge">${this._esc(badges.join(" · "))}</div>`
|
? `<div class="badge">${this._esc(badges.join(" 路 "))}</div>`
|
||||||
: `<div class="badge">${this._esc(this._safeText(r?.provider || "repo"))}</div>`;
|
: `<div class="badge">${this._esc(this._safeText(r?.provider || "repo"))}</div>`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -628,7 +659,7 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<input id="q" placeholder="Search…" value="${this._esc(this._search)}" />
|
<input id="q" placeholder="Search鈥<EFBFBD>" value="${this._esc(this._search)}" />
|
||||||
<select id="cat">
|
<select id="cat">
|
||||||
<option value="all">All categories</option>
|
<option value="all">All categories</option>
|
||||||
${categories.map((c) => `<option value="${this._esc(c)}" ${this._category === c ? "selected" : ""}>${this._esc(c)}</option>`).join("")}
|
${categories.map((c) => `<option value="${this._esc(c)}" ${this._category === c ? "selected" : ""}>${this._esc(c)}</option>`).join("")}
|
||||||
@@ -640,7 +671,7 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="muted small">Version: ${this._esc(this._data.version || "-")} · Repositories: ${repos.length}</div>
|
<div class="muted small">Version: ${this._esc(this._data.version || "-")} 路 Repositories: ${repos.length}</div>
|
||||||
|
|
||||||
<div class="grid" style="margin-top:12px;">
|
<div class="grid" style="margin-top:12px;">
|
||||||
${cards || `<div class="card">No repositories found.</div>`}
|
${cards || `<div class="card">No repositories found.</div>`}
|
||||||
@@ -713,7 +744,7 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
const readmeBlock = this._readmeLoading
|
const readmeBlock = this._readmeLoading
|
||||||
? `<div class="card">Loading README…</div>`
|
? `<div class="card">Loading README鈥<EFBFBD></div>`
|
||||||
: this._readmeText
|
: this._readmeText
|
||||||
? `
|
? `
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -748,12 +779,14 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
|
|
||||||
const busyInstall = this._installingRepoId === repoId;
|
const busyInstall = this._installingRepoId === repoId;
|
||||||
const busyUpdate = this._updatingRepoId === repoId;
|
const busyUpdate = this._updatingRepoId === repoId;
|
||||||
const busy = busyInstall || busyUpdate;
|
const busyUninstall = this._uninstallingRepoId === repoId;
|
||||||
|
const busy = busyInstall || busyUpdate || busyUninstall;
|
||||||
|
|
||||||
const updateAvailable = installed && !!latestVersion && (!installedVersion || latestVersion !== installedVersion);
|
const updateAvailable = installed && !!latestVersion && (!installedVersion || latestVersion !== installedVersion);
|
||||||
|
|
||||||
const installBtn = `<button class="primary" id="btnInstall" ${installed || busy ? "disabled" : ""}>${busyInstall ? "Installing…" : installed ? "Installed" : "Install"}</button>`;
|
const installBtn = `<button class="primary" id="btnInstall" ${installed || busy ? "disabled" : ""}>${busyInstall ? "Installing鈥<EFBFBD>" : installed ? "Installed" : "Install"}</button>`;
|
||||||
const updateBtn = `<button class="primary" id="btnUpdate" ${!updateAvailable || busy ? "disabled" : ""}>${busyUpdate ? "Updating…" : updateAvailable ? "Update" : "Up to date"}</button>`;
|
const updateBtn = `<button class="primary" id="btnUpdate" ${!updateAvailable || busy ? "disabled" : ""}>${busyUpdate ? "Updating鈥<EFBFBD>" : updateAvailable ? "Update" : "Up to date"}</button>`;
|
||||||
|
const uninstallBtn = `<button id="btnUninstall" ${!installed || busy ? "disabled" : ""}>${busyUninstall ? "Uninstalling鈥<67>" : "Uninstall"}</button>`;
|
||||||
|
|
||||||
const restartHint = this._restartRequired
|
const restartHint = this._restartRequired
|
||||||
? `
|
? `
|
||||||
@@ -777,7 +810,7 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
<div>
|
<div>
|
||||||
<div><strong style="font-size:16px;">${this._esc(name)}</strong></div>
|
<div><strong style="font-size:16px;">${this._esc(name)}</strong></div>
|
||||||
<div class="muted">${this._esc(desc)}</div>
|
<div class="muted">${this._esc(desc)}</div>
|
||||||
<div class="muted small" style="margin-top:8px;">${this._esc(infoBits.join(" · "))}</div>
|
<div class="muted small" style="margin-top:8px;">${this._esc(infoBits.join(" 路 "))}</div>
|
||||||
<div class="muted small" style="margin-top:8px;">
|
<div class="muted small" style="margin-top:8px;">
|
||||||
<a href="${this._esc(url)}" target="_blank" rel="noreferrer">Open repository</a>
|
<a href="${this._esc(url)}" target="_blank" rel="noreferrer">Open repository</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -805,6 +838,7 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
<div class="row" style="margin-top:14px; gap:10px; flex-wrap:wrap;">
|
<div class="row" style="margin-top:14px; gap:10px; flex-wrap:wrap;">
|
||||||
${installBtn}
|
${installBtn}
|
||||||
${updateBtn}
|
${updateBtn}
|
||||||
|
${uninstallBtn}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${restartHint}
|
${restartHint}
|
||||||
@@ -820,6 +854,7 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
// Always wire action buttons (even if README is already loaded)
|
// Always wire action buttons (even if README is already loaded)
|
||||||
const btnInstall = root.getElementById("btnInstall");
|
const btnInstall = root.getElementById("btnInstall");
|
||||||
const btnUpdate = root.getElementById("btnUpdate");
|
const btnUpdate = root.getElementById("btnUpdate");
|
||||||
|
const btnUninstall = root.getElementById("btnUninstall");
|
||||||
const btnRestart = root.getElementById("btnRestart");
|
const btnRestart = root.getElementById("btnRestart");
|
||||||
|
|
||||||
if (btnInstall) {
|
if (btnInstall) {
|
||||||
@@ -836,6 +871,13 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (btnUninstall) {
|
||||||
|
btnUninstall.addEventListener("click", () => {
|
||||||
|
if (btnUninstall.disabled) return;
|
||||||
|
if (this._detailRepoId) this._uninstallRepo(this._detailRepoId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (btnRestart) {
|
if (btnRestart) {
|
||||||
btnRestart.addEventListener("click", () => this._restartHA());
|
btnRestart.addEventListener("click", () => this._restartHA());
|
||||||
}
|
}
|
||||||
@@ -848,7 +890,7 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
mount.innerHTML = this._readmeHtml;
|
mount.innerHTML = this._readmeHtml;
|
||||||
this._postprocessRenderedMarkdown(mount);
|
this._postprocessRenderedMarkdown(mount);
|
||||||
} else {
|
} else {
|
||||||
mount.innerHTML = `<div class="muted">Rendered HTML not available. Use “Show raw Markdown”.</div>`;
|
mount.innerHTML = `<div class="muted">Rendered HTML not available. Use 鈥淪how raw Markdown鈥<EFBFBD>.</div>`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
mount.innerHTML = "";
|
mount.innerHTML = "";
|
||||||
@@ -883,10 +925,10 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="fabs">
|
<div class="fabs">
|
||||||
<button class="fabbtn primary" id="fabOpen" title="Open repository">↗</button>
|
<button class="fabbtn primary" id="fabOpen" title="Open repository">鈫<EFBFBD></button>
|
||||||
<button class="fabbtn" id="fabReload" title="Reload README">⟳</button>
|
<button class="fabbtn" id="fabReload" title="Reload README">鉄<EFBFBD></button>
|
||||||
<button class="fabbtn" id="fabInstall" title="${installDisabled ? (installed ? "Already installed" : "Installing…") : "Install"}" ${installDisabled ? "disabled" : ""}>+</button>
|
<button class="fabbtn" id="fabInstall" title="${installDisabled ? (installed ? "Already installed" : "Installing鈥<EFBFBD>") : "Install"}" ${installDisabled ? "disabled" : ""}>锛<EFBFBD></button>
|
||||||
<button class="fabbtn" id="fabUpdate" title="${updateDisabled ? (!installed ? "Not installed" : "No update available") : "Update"}" ${updateDisabled ? "disabled" : ""}>↑</button>
|
<button class="fabbtn" id="fabUpdate" title="${updateDisabled ? (!installed ? "Not installed" : "No update available") : "Update"}" ${updateDisabled ? "disabled" : ""}>鈫<EFBFBD></button>
|
||||||
<button class="fabbtn" id="fabInfo" title="About">i</button>
|
<button class="fabbtn" id="fabInfo" title="About">i</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,24 @@
|
|||||||
body { font-family: system-ui, sans-serif; margin:0; }
|
body { font-family: system-ui, sans-serif; margin:0; }
|
||||||
.wrap { padding: 16px; max-width: 1000px; margin: 0 auto; }
|
.wrap { padding: 16px; max-width: 1000px; margin: 0 auto; }
|
||||||
.card { border: 1px solid #ddd; border-radius: 10px; padding: 12px; margin: 10px 0; }
|
.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; }
|
.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; }
|
.muted { color: #666; font-size: 13px; margin-top: 4px; }
|
||||||
.actions { display:flex; gap: 8px; margin-top: 10px; }
|
.actions { display:flex; gap: 8px; margin-top: 10px; }
|
||||||
button { padding: 8px 12px; cursor:pointer; }
|
button { padding: 8px 12px; cursor:pointer; }
|
||||||
button[disabled] { opacity: 0.6; cursor: not-allowed; }
|
button[disabled] { opacity: 0.6; cursor: not-allowed; }
|
||||||
a { color: inherit; }
|
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; }
|
||||||
|
|||||||
@@ -189,9 +189,11 @@ async def _github_latest_version_redirect(hass: HomeAssistant, owner: str, repo:
|
|||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
async def _github_latest_version_api(hass: HomeAssistant, owner: str, repo: str) -> tuple[str | None, str | None]:
|
async def _github_latest_version_api(
|
||||||
|
hass: HomeAssistant, owner: str, repo: str, *, github_token: str | None = None
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
session = async_get_clientsession(hass)
|
session = async_get_clientsession(hass)
|
||||||
headers = {"Accept": "application/vnd.github+json", "User-Agent": UA}
|
headers = _github_headers(github_token)
|
||||||
|
|
||||||
data, status = await _safe_json(session, f"https://api.github.com/repos/{owner}/{repo}/releases/latest", headers=headers)
|
data, status = await _safe_json(session, f"https://api.github.com/repos/{owner}/{repo}/releases/latest", headers=headers)
|
||||||
if isinstance(data, dict) and data.get("tag_name"):
|
if isinstance(data, dict) and data.get("tag_name"):
|
||||||
@@ -217,12 +219,14 @@ async def _github_latest_version_api(hass: HomeAssistant, owner: str, repo: str)
|
|||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
async def _github_latest_version(hass: HomeAssistant, owner: str, repo: str) -> tuple[str | None, str | None]:
|
async def _github_latest_version(
|
||||||
|
hass: HomeAssistant, owner: str, repo: str, *, github_token: str | None = None
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
tag, src = await _github_latest_version_redirect(hass, owner, repo)
|
tag, src = await _github_latest_version_redirect(hass, owner, repo)
|
||||||
if tag:
|
if tag:
|
||||||
return tag, src
|
return tag, src
|
||||||
|
|
||||||
tag, src = await _github_latest_version_api(hass, owner, repo)
|
tag, src = await _github_latest_version_api(hass, owner, repo, github_token=github_token)
|
||||||
if tag:
|
if tag:
|
||||||
return tag, src
|
return tag, src
|
||||||
|
|
||||||
@@ -316,7 +320,16 @@ async def _gitlab_latest_version(
|
|||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
async def fetch_repo_info(hass: HomeAssistant, repo_url: str) -> RepoInfo:
|
def _github_headers(github_token: str | None = None) -> dict:
|
||||||
|
headers = {"Accept": "application/vnd.github+json", "User-Agent": UA}
|
||||||
|
token = (github_token or "").strip()
|
||||||
|
if token:
|
||||||
|
# PAT or fine-grained token
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_repo_info(hass: HomeAssistant, repo_url: str, *, github_token: str | None = None) -> RepoInfo:
|
||||||
provider = detect_provider(repo_url)
|
provider = detect_provider(repo_url)
|
||||||
owner, repo = _split_owner_repo(repo_url)
|
owner, repo = _split_owner_repo(repo_url)
|
||||||
|
|
||||||
@@ -337,7 +350,7 @@ async def fetch_repo_info(hass: HomeAssistant, repo_url: str) -> RepoInfo:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if provider == "github":
|
if provider == "github":
|
||||||
headers = {"Accept": "application/vnd.github+json", "User-Agent": UA}
|
headers = _github_headers(github_token)
|
||||||
data, status = await _safe_json(session, f"https://api.github.com/repos/{owner}/{repo}", headers=headers)
|
data, status = await _safe_json(session, f"https://api.github.com/repos/{owner}/{repo}", headers=headers)
|
||||||
|
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
@@ -356,7 +369,7 @@ async def fetch_repo_info(hass: HomeAssistant, repo_url: str) -> RepoInfo:
|
|||||||
if desc:
|
if desc:
|
||||||
info.description = desc
|
info.description = desc
|
||||||
|
|
||||||
ver, src = await _github_latest_version(hass, owner, repo)
|
ver, src = await _github_latest_version(hass, owner, repo, github_token=github_token)
|
||||||
info.latest_version = ver
|
info.latest_version = ver
|
||||||
info.latest_version_source = src
|
info.latest_version_source = src
|
||||||
return info
|
return info
|
||||||
@@ -504,4 +517,239 @@ async def fetch_readme_markdown(
|
|||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return None
|
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
|
||||||
@@ -25,6 +25,8 @@ class InstalledRepo:
|
|||||||
url: str
|
url: str
|
||||||
domains: list[str]
|
domains: list[str]
|
||||||
installed_at: int
|
installed_at: int
|
||||||
|
install_type: str = "integration"
|
||||||
|
installed_paths: list[str] | None = None
|
||||||
installed_version: str | None = None # BCS "installed ref" (tag/release/branch)
|
installed_version: str | None = None # BCS "installed ref" (tag/release/branch)
|
||||||
installed_manifest_version: str | None = None # informational only
|
installed_manifest_version: str | None = None # informational only
|
||||||
ref: str | None = None # kept for backward compatibility / diagnostics
|
ref: str | None = None # kept for backward compatibility / diagnostics
|
||||||
@@ -36,6 +38,9 @@ class BCSStorage:
|
|||||||
Keys:
|
Keys:
|
||||||
- custom_repos: list of manually added repositories
|
- custom_repos: list of manually added repositories
|
||||||
- installed_repos: mapping repo_id -> installed metadata
|
- 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:
|
def __init__(self, hass: HomeAssistant) -> None:
|
||||||
@@ -53,8 +58,92 @@ class BCSStorage:
|
|||||||
if "installed_repos" not in data or not isinstance(data.get("installed_repos"), dict):
|
if "installed_repos" not in data or not isinstance(data.get("installed_repos"), dict):
|
||||||
data["installed_repos"] = {}
|
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
|
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:
|
async def _save(self, data: dict[str, Any]) -> None:
|
||||||
await self._store.async_save(data)
|
await self._store.async_save(data)
|
||||||
|
|
||||||
@@ -110,6 +199,11 @@ class BCSStorage:
|
|||||||
if not isinstance(domains, list):
|
if not isinstance(domains, list):
|
||||||
domains = []
|
domains = []
|
||||||
domains = [str(d) for d in domains if str(d).strip()]
|
domains = [str(d) for d in domains if str(d).strip()]
|
||||||
|
installed_paths = entry.get("installed_paths") or []
|
||||||
|
if not isinstance(installed_paths, list):
|
||||||
|
installed_paths = []
|
||||||
|
installed_paths = [str(p) for p in installed_paths if str(p).strip()]
|
||||||
|
install_type = str(entry.get("install_type") or "integration").strip() or "integration"
|
||||||
|
|
||||||
installed_version = entry.get("installed_version")
|
installed_version = entry.get("installed_version")
|
||||||
ref = entry.get("ref")
|
ref = entry.get("ref")
|
||||||
@@ -126,6 +220,8 @@ class BCSStorage:
|
|||||||
url=str(entry.get("url") or ""),
|
url=str(entry.get("url") or ""),
|
||||||
domains=domains,
|
domains=domains,
|
||||||
installed_at=int(entry.get("installed_at") or 0),
|
installed_at=int(entry.get("installed_at") or 0),
|
||||||
|
install_type=install_type,
|
||||||
|
installed_paths=installed_paths,
|
||||||
installed_version=str(installed_version) if installed_version else None,
|
installed_version=str(installed_version) if installed_version else None,
|
||||||
installed_manifest_version=str(installed_manifest_version) if installed_manifest_version else None,
|
installed_manifest_version=str(installed_manifest_version) if installed_manifest_version else None,
|
||||||
ref=str(ref) if ref else None,
|
ref=str(ref) if ref else None,
|
||||||
@@ -151,9 +247,11 @@ class BCSStorage:
|
|||||||
repo_id: str,
|
repo_id: str,
|
||||||
url: str,
|
url: str,
|
||||||
domains: list[str],
|
domains: list[str],
|
||||||
installed_version: str | None,
|
installed_version: str | None = None,
|
||||||
installed_manifest_version: str | None = None,
|
installed_manifest_version: str | None = None,
|
||||||
ref: str | None,
|
ref: str | None = None,
|
||||||
|
install_type: str = "integration",
|
||||||
|
installed_paths: list[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
data = await self._load()
|
data = await self._load()
|
||||||
installed = data.get("installed_repos", {})
|
installed = data.get("installed_repos", {})
|
||||||
@@ -165,6 +263,8 @@ class BCSStorage:
|
|||||||
"repo_id": str(repo_id),
|
"repo_id": str(repo_id),
|
||||||
"url": str(url),
|
"url": str(url),
|
||||||
"domains": [str(d) for d in (domains or []) if str(d).strip()],
|
"domains": [str(d) for d in (domains or []) if str(d).strip()],
|
||||||
|
"install_type": str(install_type or "integration"),
|
||||||
|
"installed_paths": [str(p) for p in (installed_paths or []) if str(p).strip()],
|
||||||
"installed_at": int(time.time()),
|
"installed_at": int(time.time()),
|
||||||
# IMPORTANT: this is what BCS uses as "installed version" (ref/tag/branch)
|
# IMPORTANT: this is what BCS uses as "installed version" (ref/tag/branch)
|
||||||
"installed_version": installed_version,
|
"installed_version": installed_version,
|
||||||
@@ -181,4 +281,4 @@ class BCSStorage:
|
|||||||
if isinstance(installed, dict) and repo_id in installed:
|
if isinstance(installed, dict) and repo_id in installed:
|
||||||
installed.pop(repo_id, None)
|
installed.pop(repo_id, None)
|
||||||
data["installed_repos"] = installed
|
data["installed_repos"] = installed
|
||||||
await self._save(data)
|
await self._save(data)
|
||||||
|
|||||||
@@ -1,8 +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": {
|
"issues": {
|
||||||
"restart_required": {
|
"restart_required": {
|
||||||
"title": "Restart required",
|
"title": "Restart required",
|
||||||
"description": "One or more integrations were installed or updated by Bahmcloud Store. Restart Home Assistant to load the changes."
|
"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,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -9,12 +10,23 @@ from homeassistant.core import HomeAssistant, callback
|
|||||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.entity import EntityCategory
|
from homeassistant.helpers.entity import EntityCategory
|
||||||
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
|
||||||
from .core import DOMAIN, SIGNAL_UPDATED, BCSCore
|
from .const import DOMAIN
|
||||||
|
from .core import SIGNAL_UPDATED, BCSCore
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_core(hass: HomeAssistant) -> BCSCore | None:
|
||||||
|
data = hass.data.get(DOMAIN)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
c = data.get("_core")
|
||||||
|
return c if isinstance(c, BCSCore) else None
|
||||||
|
# Backwards compatibility (older versions used hass.data[DOMAIN] = core)
|
||||||
|
return data if isinstance(data, BCSCore) else None
|
||||||
|
|
||||||
|
|
||||||
def _pretty_repo_name(core: BCSCore, repo_id: str) -> str:
|
def _pretty_repo_name(core: BCSCore, repo_id: str) -> str:
|
||||||
"""Return a human-friendly name for a repo update entity."""
|
"""Return a human-friendly name for a repo update entity."""
|
||||||
try:
|
try:
|
||||||
@@ -26,7 +38,6 @@ def _pretty_repo_name(core: BCSCore, repo_id: str) -> str:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Fallbacks
|
|
||||||
if repo_id.startswith("index:"):
|
if repo_id.startswith("index:"):
|
||||||
return f"BCS Index {repo_id.split(':', 1)[1]}"
|
return f"BCS Index {repo_id.split(':', 1)[1]}"
|
||||||
if repo_id.startswith("custom:"):
|
if repo_id.startswith("custom:"):
|
||||||
@@ -53,11 +64,11 @@ class BCSRepoUpdateEntity(UpdateEntity):
|
|||||||
# Stable unique id (do NOT change)
|
# Stable unique id (do NOT change)
|
||||||
self._attr_unique_id = f"{DOMAIN}:{repo_id}"
|
self._attr_unique_id = f"{DOMAIN}:{repo_id}"
|
||||||
|
|
||||||
# Human-friendly name in UI
|
self._refresh_display_name()
|
||||||
pretty = _pretty_repo_name(core, repo_id)
|
|
||||||
self._attr_name = pretty
|
|
||||||
|
|
||||||
# Title shown in the entity dialog
|
def _refresh_display_name(self) -> None:
|
||||||
|
pretty = _pretty_repo_name(self._core, self._repo_id)
|
||||||
|
self._attr_name = pretty
|
||||||
self._attr_title = pretty
|
self._attr_title = pretty
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -102,9 +113,7 @@ class BCSRepoUpdateEntity(UpdateEntity):
|
|||||||
|
|
||||||
async def async_install(self, version: str | None, backup: bool, **kwargs: Any) -> None:
|
async def async_install(self, version: str | None, backup: bool, **kwargs: Any) -> None:
|
||||||
if version is not None:
|
if version is not None:
|
||||||
_LOGGER.debug(
|
_LOGGER.debug("BCS update entity requested specific version=%s (ignored)", version)
|
||||||
"BCS update entity requested specific version=%s (ignored)", version
|
|
||||||
)
|
|
||||||
|
|
||||||
self._in_progress = True
|
self._in_progress = True
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
@@ -117,19 +126,46 @@ class BCSRepoUpdateEntity(UpdateEntity):
|
|||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _sync_entities(
|
def _sync_entities(core: BCSCore, existing: dict[str, BCSRepoUpdateEntity], async_add_entities: AddEntitiesCallback) -> None:
|
||||||
core: BCSCore,
|
"""Ensure there is one update entity per installed repo AND keep names in sync."""
|
||||||
existing: dict[str, BCSRepoUpdateEntity],
|
|
||||||
async_add_entities: AddEntitiesCallback,
|
|
||||||
) -> None:
|
|
||||||
"""Ensure there is one update entity per installed repo."""
|
|
||||||
installed_map = getattr(core, "_installed_cache", {}) or {}
|
installed_map = getattr(core, "_installed_cache", {}) or {}
|
||||||
|
installed_repo_ids = {repo_id for repo_id, data in installed_map.items() if isinstance(data, dict)}
|
||||||
new_entities: list[BCSRepoUpdateEntity] = []
|
new_entities: list[BCSRepoUpdateEntity] = []
|
||||||
|
|
||||||
|
stale_repo_ids = [repo_id for repo_id in list(existing) if repo_id not in installed_repo_ids]
|
||||||
|
if stale_repo_ids:
|
||||||
|
registry = er.async_get(core.hass)
|
||||||
|
for repo_id in stale_repo_ids:
|
||||||
|
ent = existing.pop(repo_id, None)
|
||||||
|
if not ent:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
entity_id = getattr(ent, "entity_id", None)
|
||||||
|
if not entity_id:
|
||||||
|
for entry in registry.entities.values():
|
||||||
|
if getattr(entry, "platform", None) == DOMAIN and getattr(entry, "unique_id", None) == ent.unique_id:
|
||||||
|
entity_id = entry.entity_id
|
||||||
|
break
|
||||||
|
if entity_id:
|
||||||
|
registry.async_remove(entity_id)
|
||||||
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||||
|
_LOGGER.debug("BCS could not remove stale update entity for %s: %s", repo_id, exc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = ent.async_remove()
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
core.hass.async_create_task(result)
|
||||||
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||||
|
_LOGGER.debug("BCS could not detach stale update entity for %s: %s", repo_id, exc)
|
||||||
|
|
||||||
for repo_id, data in installed_map.items():
|
for repo_id, data in installed_map.items():
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if repo_id in existing:
|
if repo_id in existing:
|
||||||
|
# IMPORTANT: Update display name after refresh, when repo.name becomes available.
|
||||||
|
existing[repo_id]._refresh_display_name()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
ent = BCSRepoUpdateEntity(core, repo_id)
|
ent = BCSRepoUpdateEntity(core, repo_id)
|
||||||
@@ -150,7 +186,7 @@ async def async_setup_platform(
|
|||||||
discovery_info=None,
|
discovery_info=None,
|
||||||
):
|
):
|
||||||
"""Set up BCS update entities."""
|
"""Set up BCS update entities."""
|
||||||
core: BCSCore | None = hass.data.get(DOMAIN)
|
core: BCSCore | None = _get_core(hass)
|
||||||
if not core:
|
if not core:
|
||||||
_LOGGER.debug("BCS core not available, skipping update platform setup")
|
_LOGGER.debug("BCS core not available, skipping update platform setup")
|
||||||
return
|
return
|
||||||
@@ -163,4 +199,13 @@ async def async_setup_platform(
|
|||||||
def _handle_update() -> None:
|
def _handle_update() -> None:
|
||||||
_sync_entities(core, entities, async_add_entities)
|
_sync_entities(core, entities, async_add_entities)
|
||||||
|
|
||||||
async_dispatcher_connect(hass, SIGNAL_UPDATED, _handle_update)
|
async_dispatcher_connect(hass, SIGNAL_UPDATED, _handle_update)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up BCS update entities from a config entry."""
|
||||||
|
await async_setup_platform(hass, {}, async_add_entities, None)
|
||||||
|
|||||||
@@ -215,7 +215,12 @@ class BCSApiView(HomeAssistantView):
|
|||||||
|
|
||||||
async def get(self, request: web.Request) -> web.Response:
|
async def get(self, request: web.Request) -> web.Response:
|
||||||
return web.json_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:
|
async def post(self, request: web.Request) -> web.Response:
|
||||||
@@ -248,6 +253,40 @@ class BCSApiView(HomeAssistantView):
|
|||||||
return web.json_response({"ok": False, "message": "Unknown operation"}, status=400)
|
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):
|
class BCSCustomRepoView(HomeAssistantView):
|
||||||
url = "/api/bcs/custom_repo"
|
url = "/api/bcs/custom_repo"
|
||||||
name = "api:bcs_custom_repo"
|
name = "api:bcs_custom_repo"
|
||||||
@@ -292,6 +331,62 @@ class BCSReadmeView(HomeAssistantView):
|
|||||||
return web.json_response({"ok": True, "readme": md_str, "html": html})
|
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):
|
class BCSInstallView(HomeAssistantView):
|
||||||
url = "/api/bcs/install"
|
url = "/api/bcs/install"
|
||||||
name = "api:bcs_install"
|
name = "api:bcs_install"
|
||||||
@@ -302,11 +397,13 @@ class BCSInstallView(HomeAssistantView):
|
|||||||
|
|
||||||
async def post(self, request: web.Request) -> web.Response:
|
async def post(self, request: web.Request) -> web.Response:
|
||||||
repo_id = request.query.get("repo_id")
|
repo_id = request.query.get("repo_id")
|
||||||
|
version = request.query.get("version")
|
||||||
if not repo_id:
|
if not repo_id:
|
||||||
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
|
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await self.core.install_repo(repo_id)
|
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)
|
return web.json_response(result, status=200)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_LOGGER.exception("BCS install failed: %s", e)
|
_LOGGER.exception("BCS install failed: %s", e)
|
||||||
@@ -323,17 +420,87 @@ class BCSUpdateView(HomeAssistantView):
|
|||||||
|
|
||||||
async def post(self, request: web.Request) -> web.Response:
|
async def post(self, request: web.Request) -> web.Response:
|
||||||
repo_id = request.query.get("repo_id")
|
repo_id = request.query.get("repo_id")
|
||||||
|
version = request.query.get("version")
|
||||||
if not repo_id:
|
if not repo_id:
|
||||||
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
|
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await self.core.update_repo(repo_id)
|
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)
|
return web.json_response(result, status=200)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_LOGGER.exception("BCS update failed: %s", e)
|
_LOGGER.exception("BCS update failed: %s", e)
|
||||||
return web.json_response({"ok": False, "message": str(e) or "Update failed"}, status=500)
|
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):
|
class BCSRestartView(HomeAssistantView):
|
||||||
url = "/api/bcs/restart"
|
url = "/api/bcs/restart"
|
||||||
name = "api:bcs_restart"
|
name = "api:bcs_restart"
|
||||||
@@ -348,4 +515,64 @@ class BCSRestartView(HomeAssistantView):
|
|||||||
return web.json_response({"ok": True})
|
return web.json_response({"ok": True})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_LOGGER.exception("BCS restart failed: %s", e)
|
_LOGGER.exception("BCS restart failed: %s", e)
|
||||||
return web.json_response({"ok": False, "message": str(e) or "Restart failed"}, status=500)
|
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