Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 599b8e0b7d | |||
| 313fcf94e7 | |||
| d976ab56e3 | |||
| 80c1c2966f | |||
| de3fbf1a12 | |||
| a55281938c | |||
| 48f8ef6265 | |||
| 9448176ff4 | |||
| a029738ec8 | |||
| 754540d578 | |||
| 1103c5e326 | |||
| b7d4d0ded4 |
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>
|
||||||
49
CHANGELOG.md
49
CHANGELOG.md
@@ -11,6 +11,55 @@ 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
|
## 0.7.2 – 2026-01-20
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
76
README.md
76
README.md
@@ -1,50 +1,86 @@
|
|||||||
# Bahmcloud Store (BCS) for Home Assistant
|
# Bahmcloud Store (BCS) for Home Assistant
|
||||||
|
|
||||||
Bahmcloud Store (BCS) is a provider-neutral store for Home Assistant custom integrations.
|
Bahmcloud Store (BCS) is a provider-neutral store for Home Assistant content hosted on git platforms.
|
||||||
It allows you to browse, install, update, downgrade, uninstall and restore integrations
|
It currently supports installing and managing:
|
||||||
directly from the Home Assistant UI.
|
|
||||||
|
|
||||||
This README is intended for **end users**.
|
- Integrations
|
||||||
|
- Blueprints
|
||||||
|
|
||||||
|
Supported providers today:
|
||||||
|
|
||||||
|
- GitHub
|
||||||
|
- GitLab
|
||||||
|
- Gitea / Forgejo-style hosts
|
||||||
|
- Custom repositories added by URL when the host behaves compatibly
|
||||||
|
|
||||||
|
This README is for end users.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Browse repositories from the official BCS index, HACS integration source, and custom repository URLs
|
||||||
|
- Install, update, downgrade, and uninstall integrations directly from Home Assistant
|
||||||
|
- Install blueprint repositories directly into the Home Assistant blueprints folder
|
||||||
|
- Read repository README files inside the store UI
|
||||||
|
- View release notes for provider releases
|
||||||
|
- Pin repositories so important items stay easy to find
|
||||||
|
- Use native Home Assistant update entities for installed integrations
|
||||||
|
- Create and restore backups for integration installs and updates
|
||||||
|
- Create and restore blueprint content backups during blueprint updates and restore actions
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Option A: Home Assistant OS / Supervised (Add-on – recommended)
|
### Option A: Home Assistant OS / Supervised
|
||||||
|
|
||||||
1. Open **Settings → Add-ons → Add-on Store**
|
1. Open **Settings -> Add-ons -> Add-on Store**
|
||||||
2. Add the Bahmcloud Add-on repository
|
2. Add the Bahmcloud add-on repository:
|
||||||
`https://git.bahmcloud.de/bahmcloud/addons`
|
`https://git.bahmcloud.de/bahmcloud/addons`
|
||||||
3. Install **Bahmcloud Store Installer**
|
3. Install **Bahmcloud Store Installer**
|
||||||
4. Start the add-on
|
4. Start the add-on
|
||||||
5. Restart Home Assistant
|
5. Restart Home Assistant
|
||||||
|
|
||||||
Installed to:
|
Installed to:
|
||||||
/config/custom_components/bahmcloud_store
|
`/config/custom_components/bahmcloud_store`
|
||||||
|
|
||||||
---
|
### Option B: Manual Installation
|
||||||
|
|
||||||
### Option B: Manual Installation (Container / Core)
|
|
||||||
|
|
||||||
1. Download the latest release
|
1. Download the latest release
|
||||||
2. Copy `custom_components/bahmcloud_store` to:
|
2. Copy `custom_components/bahmcloud_store` to:
|
||||||
/config/custom_components/bahmcloud_store
|
`/config/custom_components/bahmcloud_store`
|
||||||
3. Restart Home Assistant
|
3. Restart Home Assistant
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Repository Sources
|
## Repository Sources
|
||||||
|
|
||||||
- **BCS Official**
|
- **BCS Official**: repositories from the Bahmcloud store index
|
||||||
- **HACS**
|
- **HACS**: optional official HACS integration source
|
||||||
- **Custom**
|
- **Custom**: repositories you add manually by URL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Content Support
|
||||||
|
|
||||||
|
### Integrations
|
||||||
|
|
||||||
|
- Expected repository content: `custom_components/<domain>/...`
|
||||||
|
- Install target: `/config/custom_components/<domain>`
|
||||||
|
- Supports install, update, uninstall, backup, restore, version selection, and update entities
|
||||||
|
- Restart required after install, update, uninstall, or restore
|
||||||
|
|
||||||
|
### Blueprints
|
||||||
|
|
||||||
|
- Expected repository content: `blueprints/...`
|
||||||
|
- Install target: `/config/blueprints/...`
|
||||||
|
- Supports install, update, uninstall, backup, and restore through the store
|
||||||
|
- No restart is normally required for blueprint deployment
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
Developer documentation:
|
- Developer documentation: `README_DEVELOPER.md`
|
||||||
https://git.bahmcloud.de/bahmcloud/bahmcloud_store/src/branch/main/README_DEVELOPER.md
|
- Full user guide: `README_FULL.md`
|
||||||
|
|
||||||
Full technical documentation:
|
|
||||||
https://git.bahmcloud.de/bahmcloud/bahmcloud_store/src/branch/main/README_FULL.md
|
|
||||||
|
|||||||
@@ -1,115 +1,159 @@
|
|||||||
# Bahmcloud Store – Developer Documentation
|
# Bahmcloud Store - Developer Documentation
|
||||||
|
|
||||||
For contributors and maintainers.
|
For contributors and maintainers.
|
||||||
|
|
||||||
## Architecture
|
## Project Scope
|
||||||
|
|
||||||
Repositories:
|
Bahmcloud Store is evolving from an integration-only store into a broader Home Assistant git-based content store.
|
||||||
1) Installer Add-on (HAOS/Supervised) ```https://git.bahmcloud.de/bahmcloud/addons```
|
|
||||||
2) Core Integration ```https://git.bahmcloud.de/bahmcloud/bahmcloud_store```
|
|
||||||
3) Store Index (`store.yaml`) ```https://git.bahmcloud.de/bahmcloud/ha_store```
|
|
||||||
|
|
||||||
### Integration Layout
|
Currently supported install categories:
|
||||||
|
|
||||||
custom_components/bahmcloud_store/
|
- Integrations
|
||||||
- __init__.py: setup, panel registration, schedule background after HA started
|
- Blueprints
|
||||||
- core.py: index merge, enrichment, install/update/uninstall, backups, restore, caching
|
|
||||||
- providers.py: GitHub/GitLab/Gitea repo info + latest version helpers
|
Planned categories:
|
||||||
- metadata.py: read bcs.yaml / hacs.json / hacs.yaml
|
|
||||||
- storage.py: persistent storage (installed, custom, repo cache, hacs cache)
|
- Templates
|
||||||
- views.py: HTTP API endpoints
|
- Lovelace / dashboard designs
|
||||||
- update.py: UpdateEntity implementation
|
- Additional content types with category-specific install logic
|
||||||
- repairs.py: (optional) Repairs flow for restart
|
|
||||||
- panel/: UI (panel.js, styles.css, etc.)
|
## Repository Layout
|
||||||
- manifest.json
|
|
||||||
|
Repositories related to the project:
|
||||||
|
|
||||||
|
1. Installer add-on:
|
||||||
|
`https://git.bahmcloud.de/bahmcloud/addons`
|
||||||
|
2. Core integration:
|
||||||
|
`https://git.bahmcloud.de/bahmcloud/bahmcloud_store`
|
||||||
|
3. Store index:
|
||||||
|
`https://git.bahmcloud.de/bahmcloud/ha_store`
|
||||||
|
|
||||||
|
## Integration Layout
|
||||||
|
|
||||||
|
`custom_components/bahmcloud_store/`
|
||||||
|
|
||||||
|
- `__init__.py`: setup, panel registration, delayed startup refresh, periodic refresh
|
||||||
|
- `core.py`: index merge, repo enrichment, install/update/uninstall, backup/restore, category-aware install groundwork
|
||||||
|
- `providers.py`: provider detection, latest version lookup, release notes, README fetching
|
||||||
|
- `metadata.py`: reads `bcs.yaml`, `hacs.json`, and `hacs.yaml`
|
||||||
|
- `storage.py`: persistent storage for installed repos, settings, caches
|
||||||
|
- `views.py`: HTTP API
|
||||||
|
- `update.py`: Home Assistant update entities
|
||||||
|
- `repairs.py`: restart-required repair flow
|
||||||
|
- `panel/`: active frontend panel
|
||||||
|
- `manifest.json`
|
||||||
|
|
||||||
## Runtime Model
|
## Runtime Model
|
||||||
|
|
||||||
- RepoItem (merged)
|
- `RepoItem`: merged repository model used by the UI and backend
|
||||||
- Installed repos (storage)
|
- Installed repositories: persisted in Home Assistant storage
|
||||||
- Repo cache (persisted enrichment)
|
- Settings: persistent UI and behavior settings such as HACS enablement and pinned repositories
|
||||||
- HACS meta cache (mapping owner/repo → name/description)
|
- Repo cache: provider and metadata enrichment cache
|
||||||
|
- HACS cache: display metadata cache for HACS integration repositories
|
||||||
## Background
|
|
||||||
|
|
||||||
- Heavy work only after `homeassistant_started`
|
|
||||||
- Refresh: if index unchanged → installed-only refresh + schedule enrichment
|
|
||||||
- Opening a repo triggers `ensure_repo_details()` and persists to cache
|
|
||||||
|
|
||||||
## Providers
|
|
||||||
|
|
||||||
- GitHub: API/releases/tags/atom + raw readme
|
|
||||||
- GitLab: API releases/tags + raw readme
|
|
||||||
- Gitea: API releases/tags + raw readme
|
|
||||||
- Custom: API or HTTPS Request to your Git Provider
|
|
||||||
|
|
||||||
## Metadata
|
## Metadata
|
||||||
|
|
||||||
- Prefer `bcs.yaml`, fallback `hacs.json` / `hacs.yaml`
|
Metadata priority:
|
||||||
- Populate name/description/category/author/maintainer
|
|
||||||
|
|
||||||
## HTTP API (excerpt)
|
1. `bcs.yaml`
|
||||||
|
2. `hacs.yaml`
|
||||||
|
3. `hacs.json`
|
||||||
|
|
||||||
Base: /api/bcs
|
Common fields:
|
||||||
|
|
||||||
- GET /api/bcs
|
|
||||||
- POST /api/bcs?action=refresh
|
|
||||||
- GET /api/bcs/readme?repo_id=...
|
|
||||||
- GET /api/bcs/versions?repo_id=...
|
|
||||||
- POST /api/bcs/install?repo_id=...&version=...
|
|
||||||
- POST /api/bcs/update?repo_id=...&version=...
|
|
||||||
- POST /api/bcs/uninstall?repo_id=...
|
|
||||||
- GET /api/bcs/backups?repo_id=...
|
|
||||||
- POST /api/bcs/restore?repo_id=...&backup_id=...
|
|
||||||
- (optional) POST/DELETE custom_repo
|
|
||||||
|
|
||||||
## Update Entities
|
|
||||||
|
|
||||||
- Unique id bcs:<repo_id>
|
|
||||||
- Compare installed ref vs latest ref
|
|
||||||
- Dispatcher signal on refresh/install/update
|
|
||||||
|
|
||||||
## Storage
|
|
||||||
|
|
||||||
- JSON in HA `.storage`
|
|
||||||
- async read/write helpers
|
|
||||||
- repo cache applied on startup
|
|
||||||
|
|
||||||
## Contributing to **BCS Official**
|
|
||||||
|
|
||||||
1) Add pull request to `https://git.bahmcloud.de/bahmcloud/ha_store` (with your integration added to) `store.yaml`)
|
|
||||||
2) Add entry:
|
|
||||||
```yaml
|
```yaml
|
||||||
- name: Your Integration Name
|
name: Example Project
|
||||||
url: https://your-git-hoster.com/your-org/your-repo
|
description: Short description
|
||||||
category: Category (actually only "Integrations" are supported)
|
category: Integrations
|
||||||
|
author: Example Author
|
||||||
|
maintainer: Example Maintainer
|
||||||
```
|
```
|
||||||
3) (Recommended) Add `bcs.yaml` to your repo:
|
|
||||||
|
Category currently matters operationally:
|
||||||
|
|
||||||
|
- `Integrations` -> install from `custom_components/...`
|
||||||
|
- `Blueprint` / `Blueprints` -> install from `blueprints/...`
|
||||||
|
|
||||||
|
## Supported Install Categories
|
||||||
|
|
||||||
|
### Integrations
|
||||||
|
|
||||||
|
- Expected source layout: `custom_components/<domain>/manifest.json`
|
||||||
|
- Install target: `/config/custom_components/<domain>`
|
||||||
|
- Supports backup, restore, update entities, and restart-required flow
|
||||||
|
|
||||||
|
### Blueprints
|
||||||
|
|
||||||
|
- Expected source layout: `blueprints/...`
|
||||||
|
- Install target: `/config/blueprints/...`
|
||||||
|
- Supports install, update, uninstall, content backup, and content restore
|
||||||
|
- Restores and uninstalls are path-based and operate on the recorded installed blueprint files
|
||||||
|
- Category-aware groundwork is in place so future content types can use their own install strategies
|
||||||
|
|
||||||
|
## HTTP API
|
||||||
|
|
||||||
|
Base path: `/api/bcs`
|
||||||
|
|
||||||
|
- `GET /api/bcs`
|
||||||
|
- `POST /api/bcs?action=refresh`
|
||||||
|
- `GET /api/bcs/settings`
|
||||||
|
- `POST /api/bcs/settings`
|
||||||
|
- `GET /api/bcs/readme?repo_id=...`
|
||||||
|
- `GET /api/bcs/release_notes?repo_id=...&ref=...`
|
||||||
|
- `GET /api/bcs/versions?repo_id=...`
|
||||||
|
- `GET /api/bcs/repo?repo_id=...`
|
||||||
|
- `POST /api/bcs/install?repo_id=...&version=...`
|
||||||
|
- `POST /api/bcs/update?repo_id=...&version=...`
|
||||||
|
- `POST /api/bcs/uninstall?repo_id=...`
|
||||||
|
- `GET /api/bcs/backups?repo_id=...`
|
||||||
|
- `POST /api/bcs/restore?repo_id=...&backup_id=...`
|
||||||
|
- `POST /api/bcs/restart`
|
||||||
|
- `DELETE /api/bcs/custom_repo?id=...`
|
||||||
|
|
||||||
|
## Current UI Features
|
||||||
|
|
||||||
|
- Search, source filter, category filter, state filter, sorting
|
||||||
|
- HACS integration source toggle
|
||||||
|
- Pinned repositories
|
||||||
|
- README rendering
|
||||||
|
- Release notes rendering
|
||||||
|
- Version selection
|
||||||
|
- Backup restore UI for integrations and blueprints
|
||||||
|
|
||||||
|
## Contributing to the Official Store Index
|
||||||
|
|
||||||
|
Add a repository to the BCS store index in `ha_store`.
|
||||||
|
|
||||||
|
Example index entry:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
name: Your Integration Name
|
- name: Example Project
|
||||||
description: One-liner for the store (optional, store information are also catched from git repository)
|
url: https://your-git-hoster.example/org/repo
|
||||||
category: Integrations (actually only supported)
|
category: Blueprint
|
||||||
author: Your Name
|
|
||||||
maintainer: Your Handle
|
|
||||||
```
|
```
|
||||||
4) Open PR; validation checks: reachable, has `custom_components/<domain>/manifest.json`, sensible metadata
|
|
||||||
5) Merge → appears in **BCS Official** after refresh
|
|
||||||
|
|
||||||
## Coding Guidelines
|
Recommended repository metadata:
|
||||||
|
|
||||||
- Async I/O, no blocking event loop
|
```yaml
|
||||||
- Respect provider rate limits
|
name: Example Project
|
||||||
- Clean logging around refresh/install/update/restore
|
description: One-line description
|
||||||
- Keep UI responsive; throttle updates
|
category: Blueprint
|
||||||
|
author: Example Author
|
||||||
|
maintainer: Example Maintainer
|
||||||
|
```
|
||||||
|
|
||||||
---
|
Validation should match the category:
|
||||||
|
|
||||||
## Planed Features
|
- Integrations: verify `custom_components/<domain>/manifest.json`
|
||||||
|
- Blueprints: verify `blueprints/...`
|
||||||
|
|
||||||
- Add Downloads and install for category "Dashboard"
|
## Design Direction
|
||||||
- Add Downloads and install for category "Template"
|
|
||||||
- Add Downloads and install for category "Theme"
|
|
||||||
- Add Downloads and install for category "Blueprint"
|
|
||||||
-
|
|
||||||
|
|
||||||
|
The long-term architecture should remain category-aware:
|
||||||
|
|
||||||
|
- category -> validation strategy
|
||||||
|
- category -> install target
|
||||||
|
- category -> backup / restore behavior
|
||||||
|
- category -> UI affordances
|
||||||
|
|
||||||
|
This is especially important before Templates and Lovelace support are added, because those should stay compatible with established HACS expectations where possible.
|
||||||
|
|||||||
339
README_FULL.md
339
README_FULL.md
@@ -1,27 +1,30 @@
|
|||||||
# Bahmcloud Store – Full User Guide
|
# Bahmcloud Store - Full User Guide
|
||||||
|
|
||||||
This guide explains **all features** of Bahmcloud Store (BCS) for Home Assistant.
|
This guide explains the current feature set of Bahmcloud Store (BCS) for Home Assistant.
|
||||||
It is written for users and admins who want a complete, practical reference.
|
|
||||||
|
|
||||||
> BCS lets you install & manage custom integrations from **GitHub/GitLab/Gitea** and your own sources, with backups, restore, and version pinning.
|
BCS is a git-based store UI that can currently manage integrations and blueprints from multiple repository providers.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
- Concepts
|
- Concepts
|
||||||
- Sources (BCS / HACS / Custom)
|
- Sources
|
||||||
|
- Supported Content Types
|
||||||
- UI Overview
|
- UI Overview
|
||||||
- Finding Integrations
|
- Finding Repositories
|
||||||
- Installing
|
- Installing
|
||||||
- Selecting Versions / Downgrading
|
- Selecting Versions
|
||||||
- Updating
|
- Updating
|
||||||
- Uninstalling
|
- Uninstalling
|
||||||
- Backups & Restore
|
- Pinned Repositories
|
||||||
|
- Release Notes
|
||||||
|
- Backups and Restore
|
||||||
- Custom Repositories
|
- Custom Repositories
|
||||||
- HACS Repositories
|
- HACS Repositories
|
||||||
- Update Entities in Home Assistant
|
- Update Entities
|
||||||
- Background Caching & Performance
|
- Performance and Caching
|
||||||
- Restart Required
|
- Restart Behavior
|
||||||
- Troubleshooting
|
- Troubleshooting
|
||||||
- FAQ
|
- FAQ
|
||||||
|
|
||||||
@@ -29,189 +32,293 @@ It is written for users and admins who want a complete, practical reference.
|
|||||||
|
|
||||||
## Concepts
|
## Concepts
|
||||||
|
|
||||||
- **Sources**:
|
### Sources
|
||||||
- **BCS Official** → entries from index (`store.yaml`) made and added from Bahmcloud
|
|
||||||
- **HACS** → official HACS integrations list (toggleable)
|
- **BCS Official**: repositories from the official Bahmcloud index
|
||||||
- **Custom** → manual entries you add locally
|
- **HACS**: optional HACS integration source
|
||||||
- **Install location**: `/config/custom_components/<domain>`
|
- **Custom**: repositories you add manually by URL
|
||||||
- **Backup**: BCS keeps pre‑update copies in `/config/.bcs_backups/<domain>/<timestamp>/`
|
|
||||||
|
### Installed Ref
|
||||||
|
|
||||||
|
BCS stores the installed ref separately from any version declared inside the repository itself.
|
||||||
|
That makes update checks more reliable when a repository uses tags or releases that do not exactly match its internal manifest version.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sources (BCS / HACS / Custom)
|
## Supported Content Types
|
||||||
|
|
||||||
Each repository card shows a **source badge**:
|
### Integrations
|
||||||
- **BCS Official** – from Bahmcloud index
|
|
||||||
- **HACS** – from HACS official list (enable with the toggle)
|
|
||||||
- **Custom** – added by you
|
|
||||||
|
|
||||||
You can **filter by source** with the **Source** dropdown (All / BCS Official / HACS / Custom).
|
- Source layout: `custom_components/<domain>/...`
|
||||||
|
- Install target: `/config/custom_components/<domain>`
|
||||||
|
- Supports install, update, downgrade, uninstall, backups, restore, and update entities
|
||||||
|
- Restart required after install, update, uninstall, or restore
|
||||||
|
|
||||||
|
### Blueprints
|
||||||
|
|
||||||
|
- Source layout: `blueprints/...`
|
||||||
|
- Install target: `/config/blueprints/...`
|
||||||
|
- Supports install, update, uninstall, backup, and restore through the store
|
||||||
|
- Intended for blueprint repositories without integration-specific folder structures
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## UI Overview
|
## UI Overview
|
||||||
|
|
||||||
Top bar:
|
Store view:
|
||||||
- **Search** (name/description)
|
|
||||||
- **Source** filter (All/BCS Official/HACS/Custom)
|
|
||||||
- **Category** filter (actually only Integrations are supported)
|
|
||||||
- **State-Filter** filter (All/Installed/Not installed/Update available/Custom repos)
|
|
||||||
- **Sort** (name (A-Z)/Updates first/Installed first)
|
|
||||||
- **HACS official** toggle (on/off)
|
|
||||||
|
|
||||||
Repository card:
|
- Search
|
||||||
- Name, description, badges (source, installed/update), category
|
- Source filter
|
||||||
- Buttons: **Install / Update / Uninstall**
|
- Category filter
|
||||||
- **Readme** expandable
|
- State filter
|
||||||
- **Open** to see details (available versions, metadata)
|
- Sorting
|
||||||
|
- HACS source toggle
|
||||||
|
- Pinned repository filtering and sorting
|
||||||
|
|
||||||
|
Repository detail view:
|
||||||
|
|
||||||
|
- README rendering
|
||||||
|
- Release notes for provider releases
|
||||||
|
- Version selector
|
||||||
|
- Install / Update / Uninstall actions
|
||||||
|
- Pin / Unpin action
|
||||||
|
- Backup restore action for integrations
|
||||||
|
- Category and install target information
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Finding Integrations
|
## Finding Repositories
|
||||||
|
|
||||||
1. Use **Search** to filter by keywords.
|
1. Use **Search** to filter by name, description, or URL.
|
||||||
2. Combine with **Category** and **Source**.
|
2. Use **Source** to switch between BCS Official, HACS, and Custom repositories.
|
||||||
3. Sort to surface desired results.
|
3. Use **Category** to focus on a specific content type.
|
||||||
|
4. Use **Pinned** filtering or sorting to surface repositories you watch often.
|
||||||
|
|
||||||
Descriptions and latest versions are filled progressively by a background process; opening a repo loads details on demand.
|
Descriptions, metadata, and latest versions may appear progressively because BCS enriches repository information in the background.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|
||||||
1. Open a repository.
|
### Integration Install
|
||||||
2. Optionally select **Install version** (default: **Latest**).
|
|
||||||
3. Click **Install** and wait for confirmation.
|
|
||||||
4. Follow the **Restart required** prompt. (or use restart toggle in HomeAssistant Settings)
|
|
||||||
|
|
||||||
**What happens internally**
|
1. Open a repository.
|
||||||
- BCS downloads the repository ZIP for the selected version (release/tag/branch).
|
2. Optionally select a version.
|
||||||
- It extracts all integrations found under `custom_components/<domain>` and deploys them.
|
3. Click **Install**.
|
||||||
- It saves the **installed version (ref)** to track updates reliably, even if the repo’s own `manifest.json` is wrong/outdated.
|
4. Restart Home Assistant when prompted.
|
||||||
|
|
||||||
|
Internally, BCS:
|
||||||
|
|
||||||
|
- downloads the selected repository archive
|
||||||
|
- extracts `custom_components/...`
|
||||||
|
- installs every valid integration found there
|
||||||
|
- stores the installed ref
|
||||||
|
- creates backups before overwriting existing integration folders
|
||||||
|
|
||||||
|
### Blueprint Install
|
||||||
|
|
||||||
|
1. Open a blueprint repository.
|
||||||
|
2. Optionally select a version.
|
||||||
|
3. Click **Install**.
|
||||||
|
|
||||||
|
Internally, BCS:
|
||||||
|
|
||||||
|
- downloads the selected repository archive
|
||||||
|
- extracts `blueprints/...`
|
||||||
|
- copies blueprint files into `/config/blueprints/...`
|
||||||
|
|
||||||
|
Blueprint installs normally do not require a Home Assistant restart.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Selecting Versions / Downgrading
|
## Selecting Versions
|
||||||
|
|
||||||
- Use the **Install version** dropdown in the detail view.
|
- Use the version selector in the detail view.
|
||||||
- Choose **Latest** or a previous **release/tag**.
|
- `Latest` uses the newest provider release or tag BCS can determine.
|
||||||
- Installing a chosen ref **pins** the integration to that ref (no surprise updates).
|
- You can install an older release or tag when available.
|
||||||
- You can upgrade again later by selecting **Latest** and clicking **Update**.
|
- Release notes are shown when the selected version is a provider release with a body.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Updating
|
## Updating
|
||||||
|
|
||||||
- The **Update** button appears when `latest_version` differs from your **installed version (ref)**.
|
### Integrations
|
||||||
- Updates are also available via **Home Assistant → Settings → Updates** (native Update entity).
|
|
||||||
- Clicking **Update** runs the same safe pipeline as **Install** (with backup).
|
|
||||||
|
|
||||||
**Tip:** Opening a repository detail view forces an immediate check for the latest version for that repo.
|
- The **Update** button appears when the latest ref differs from the installed ref.
|
||||||
|
- Updates are also available through **Settings -> Updates** via native Home Assistant update entities.
|
||||||
|
- Integration updates create backups before overwriting files.
|
||||||
|
|
||||||
|
### Blueprints
|
||||||
|
|
||||||
|
- Blueprint repositories can also be updated or reinstalled from another selected version.
|
||||||
|
- Blueprint updates create content backups before overwriting files.
|
||||||
|
- The current blueprint path handling is focused on direct deployment to the blueprints folder.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Uninstalling
|
## Uninstalling
|
||||||
|
|
||||||
- Click **Uninstall** on the repository.
|
### Integrations
|
||||||
- BCS removes the integration folders under `custom_components/<domain>`.
|
|
||||||
- The installed state is cleared in the Store.
|
- Removes the installed integration directories from `/config/custom_components`
|
||||||
- Restart Home Assistant if prompted.
|
- Clears the installed state in BCS
|
||||||
|
- Usually requires a restart
|
||||||
|
|
||||||
|
### Blueprints
|
||||||
|
|
||||||
|
- Removes the installed blueprint files recorded by BCS
|
||||||
|
- Cleans up empty directories below `/config/blueprints` when possible
|
||||||
|
- Does not require a restart under normal conditions
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Backups & Restore
|
## Pinned Repositories
|
||||||
|
|
||||||
Before an update/install over existing files, BCS creates a backup:
|
You can pin repositories to keep important items easy to find.
|
||||||
|
|
||||||
```
|
Current behavior:
|
||||||
/config/.bcs_backups/<domain>/<timestamp>/
|
|
||||||
```
|
|
||||||
|
|
||||||
**Restore**:
|
- Pin or unpin from the detail view
|
||||||
1. Open the repository in store.
|
- Filter the store by pinned repositories
|
||||||
2. Select **Restore…**.
|
- Sort the store with pinned repositories first
|
||||||
3. Pick one of the **last backups** (up to retention limit). (Version details are showed in ())
|
- Pinned repositories show a dedicated badge in the store overview
|
||||||
4. Confirm – BCS restores files and reconciles installed version to the restored ref.
|
- Pinned state is persisted in settings
|
||||||
5. Restart Home Assistant if prompted.
|
|
||||||
|
|
||||||
If the old backup lacks metadata, BCS best‑effort derives the installed version from the backup’s `manifest.json`, or marks the ref as `restored:<timestamp>` so updates remain possible.
|
---
|
||||||
|
|
||||||
|
## Release Notes
|
||||||
|
|
||||||
|
When a provider release has a release body, BCS can show release notes in the detail view for the selected version.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Release notes are available for real releases, not every tag or branch
|
||||||
|
- If no provider release body exists, BCS shows a clear not-available state
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backups and Restore
|
||||||
|
|
||||||
|
Integration installs and updates create backups before overwriting existing files.
|
||||||
|
Blueprint updates also create content backups before overwriting deployed blueprint files.
|
||||||
|
|
||||||
|
Backup path:
|
||||||
|
|
||||||
|
`/config/.bcs_backups/<domain>/<timestamp>/`
|
||||||
|
|
||||||
|
Restore flow:
|
||||||
|
|
||||||
|
1. Open the repository detail
|
||||||
|
2. Click **Restore**
|
||||||
|
3. Select a backup
|
||||||
|
4. Confirm restore
|
||||||
|
5. Restart Home Assistant if prompted
|
||||||
|
|
||||||
|
Restore is available for integrations and blueprints.
|
||||||
|
|
||||||
|
Blueprint restore flow:
|
||||||
|
|
||||||
|
1. Open the blueprint repository detail
|
||||||
|
2. Click **Restore**
|
||||||
|
3. Select a backup
|
||||||
|
4. Confirm restore
|
||||||
|
|
||||||
|
Blueprint restores overwrite the recorded installed files under `/config/blueprints/...` and normally do not require a restart.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Custom Repositories
|
## Custom Repositories
|
||||||
|
|
||||||
You can add any public repository (GitHub/GitLab/Gitea/Any other Git Hoster). BCS will attempt to detect:
|
You can add any public repository URL that matches a supported provider style and contains supported content.
|
||||||
- provider & default branch
|
|
||||||
- latest version (release/tag/atom)
|
|
||||||
- repo metadata (prefer `bcs.yaml`, fallback `hacs.json/hacs.yaml`)
|
|
||||||
- readme (common filenames)
|
|
||||||
|
|
||||||
**Add an custom repository**(typical flows):
|
BCS attempts to detect:
|
||||||
- From the Store UI
|
|
||||||
1. If you start up Bahmcloud Store, you are on tab "Store"
|
|
||||||
2. Go to tab "Manage"
|
|
||||||
3. Add Repository URL (Example: ``https://git.bahmcloud.de/bahmcloud/bahmcloud_store``) and (optional) Name you want to show up (will be grabbed from GIT Repository)
|
|
||||||
4. Repository show up in Custom Repositories-list and in "Store" Tab, if available
|
|
||||||
|
|
||||||
**Remove an custom repository**(typical flows):
|
- provider
|
||||||
- From the Store UI
|
- default branch
|
||||||
1. If you start up Bahmcloud Store, you are on tab "Store"
|
- latest version
|
||||||
2. Go to tab "Manage"
|
- metadata from `bcs.yaml`, `hacs.yaml`, or `hacs.json`
|
||||||
3. Your repository you want to remove (if its custom) show up in the list
|
- README content
|
||||||
4. Press "Remove" Button to delete from Store (Attention: If you Remove installed Repository, you won´t be able to Uninstall from Store, you have to remove by your own (Delete Folder from ```config/custom_components/*```))
|
|
||||||
|
|
||||||
Custom repos get the **Custom** badge and can be filtered via **Source**.
|
Custom repositories get a **Custom** badge and can be filtered separately.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## HACS Repositories
|
## HACS Repositories
|
||||||
|
|
||||||
Enable the **HACS official** toggle to include official HACS integrations.
|
Enable the HACS toggle to include official HACS integration repositories.
|
||||||
|
|
||||||
- BCS downloads the HACS integration list and maps **human‑readable names/descriptions** from HACS metadata.
|
Current scope:
|
||||||
- HACS entries are **not** part of your BCS-Official Repositories (avoid duplicate entries).
|
|
||||||
|
|
||||||
With many HACS repos, metadata loads in the background; names/descriptions appear progressively and are cached.
|
- HACS source support is currently focused on integrations
|
||||||
|
- HACS metadata is used to improve naming and descriptions
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Update Entities in Home Assistant
|
## Update Entities
|
||||||
|
|
||||||
BCS exposes update entities for installed repos:
|
BCS exposes Home Assistant update entities for installed integrations.
|
||||||
- Found under **Settings → Updates**
|
|
||||||
- Clicking **Install** triggers BCS update pipeline
|
- Found under **Settings -> Updates**
|
||||||
- Shows **installed** and **latest** versions (BCS ref logic)
|
- Shows installed ref and latest ref
|
||||||
|
- Lets Home Assistant trigger the BCS update pipeline
|
||||||
|
|
||||||
|
Blueprint repositories do not currently use the integration-style update entity flow.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Background Caching & Performance
|
## Performance and Caching
|
||||||
|
|
||||||
- **Fast initial list**: index + local cache only
|
BCS is designed to keep startup responsive.
|
||||||
- **Background enrichment**: provider info, latest version, metadata, description, readme (best effort)
|
|
||||||
- **On‑demand**: opening a repo triggers immediate enrichment; data is **persisted** to cache
|
- Heavy work runs after Home Assistant startup
|
||||||
- **Persistent cache**: survives HA restarts; speeds up subsequent runs
|
- Repo data is enriched in the background
|
||||||
- **Refresh**: immediately rechecks installed repos and key metadata
|
- Repository details are cached
|
||||||
|
- Version lists are cached
|
||||||
|
- HACS metadata is cached
|
||||||
|
|
||||||
|
Opening a repository detail view can force immediate enrichment for that repository.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Restart Required
|
## Restart Behavior
|
||||||
|
|
||||||
After install, update, or restore, BCS raises a **Restart required** item in Home Assistant (Repairs). You can restart directly from there.
|
Restart usually matters only for integration changes.
|
||||||
|
|
||||||
|
- Integration install/update/uninstall/restore: restart expected
|
||||||
|
- Blueprint install/update/uninstall/restore: restart usually not needed
|
||||||
|
|
||||||
|
BCS uses a Home Assistant repair flow to surface restart requirements for integration changes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
- **New release but no update**: Open the repo detail once; ensure it’s a **release/tag** (commits alone don’t change the ref).
|
- **Latest version missing**: open the repository detail once to force a recheck
|
||||||
- **Descriptions/Latest missing**: Wait for background enrichment or open the repo detail (forces enrichment). Cached afterwards.
|
- **Release notes missing**: the selected version may be a tag or branch without a provider release body
|
||||||
(You also can Install new version by selecting in Version tab)
|
- **Blueprint install fails**: verify the repository really contains a `blueprints/` folder
|
||||||
- **Slow startup**: BCS schedules heavy work after HA started. Keep indexes reasonable.
|
- **Integration install fails**: verify the repository contains `custom_components/<domain>/manifest.json`
|
||||||
|
- **Repo appears but cannot install**: metadata can describe the category, but the repository layout still has to match that category
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
- **Backups path?** `/config/.bcs_backups/<domain>/<timestamp>/`
|
### Where are integrations installed?
|
||||||
- **Install path?** `/config/custom_components/<domain>`
|
|
||||||
- **Downgrade?** Yes, pick an older version and install.
|
`/config/custom_components/<domain>`
|
||||||
- **Restart needed?** Yes, after install/update/restore.
|
|
||||||
|
### Where are blueprints installed?
|
||||||
|
|
||||||
|
`/config/blueprints/...`
|
||||||
|
|
||||||
|
### Are pinned repositories persistent?
|
||||||
|
|
||||||
|
Yes.
|
||||||
|
|
||||||
|
### Do blueprints require a restart?
|
||||||
|
|
||||||
|
Usually no.
|
||||||
|
|
||||||
|
### Do integrations require a restart?
|
||||||
|
|
||||||
|
Yes, normally after install, update, uninstall, or restore.
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
BCSSettingsView,
|
BCSSettingsView,
|
||||||
BCSReadmeView,
|
BCSReadmeView,
|
||||||
BCSVersionsView,
|
BCSVersionsView,
|
||||||
|
BCSReleaseNotesView,
|
||||||
BCSRepoDetailView,
|
BCSRepoDetailView,
|
||||||
BCSCustomRepoView,
|
BCSCustomRepoView,
|
||||||
BCSInstallView,
|
BCSInstallView,
|
||||||
@@ -74,6 +75,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
hass.http.register_view(BCSSettingsView(core))
|
hass.http.register_view(BCSSettingsView(core))
|
||||||
hass.http.register_view(BCSReadmeView(core))
|
hass.http.register_view(BCSReadmeView(core))
|
||||||
hass.http.register_view(BCSVersionsView(core))
|
hass.http.register_view(BCSVersionsView(core))
|
||||||
|
hass.http.register_view(BCSReleaseNotesView(core))
|
||||||
hass.http.register_view(BCSRepoDetailView(core))
|
hass.http.register_view(BCSRepoDetailView(core))
|
||||||
hass.http.register_view(BCSCustomRepoView(core))
|
hass.http.register_view(BCSCustomRepoView(core))
|
||||||
hass.http.register_view(BCSInstallView(core))
|
hass.http.register_view(BCSInstallView(core))
|
||||||
@@ -88,7 +90,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
frontend_url_path="bahmcloud-store",
|
frontend_url_path="bahmcloud-store",
|
||||||
webcomponent_name="bahmcloud-store-panel",
|
webcomponent_name="bahmcloud-store-panel",
|
||||||
# IMPORTANT: bump v to avoid caching old JS
|
# IMPORTANT: bump v to avoid caching old JS
|
||||||
module_url="/api/bahmcloud_store_static/panel.js?v=109",
|
module_url="/api/bahmcloud_store_static/panel.js?v=112",
|
||||||
sidebar_title="Bahmcloud Store",
|
sidebar_title="Bahmcloud Store",
|
||||||
sidebar_icon="mdi:store",
|
sidebar_icon="mdi:store",
|
||||||
require_admin=True,
|
require_admin=True,
|
||||||
|
|||||||
@@ -20,7 +20,14 @@ from homeassistant.helpers import issue_registry as ir
|
|||||||
from homeassistant.util import yaml as ha_yaml
|
from homeassistant.util import yaml as ha_yaml
|
||||||
|
|
||||||
from .storage import BCSStorage, CustomRepo
|
from .storage import BCSStorage, CustomRepo
|
||||||
from .providers import fetch_repo_info, detect_provider, RepoInfo, fetch_readme_markdown, fetch_repo_versions
|
from .providers import (
|
||||||
|
fetch_repo_info,
|
||||||
|
detect_provider,
|
||||||
|
RepoInfo,
|
||||||
|
fetch_readme_markdown,
|
||||||
|
fetch_repo_versions,
|
||||||
|
fetch_release_notes_markdown,
|
||||||
|
)
|
||||||
from .metadata import fetch_repo_metadata, RepoMetadata
|
from .metadata import fetch_repo_metadata, RepoMetadata
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -111,7 +118,7 @@ class BCSCore:
|
|||||||
self._installed_cache: dict[str, Any] = {}
|
self._installed_cache: dict[str, Any] = {}
|
||||||
|
|
||||||
# Persistent settings (UI toggles etc.)
|
# Persistent settings (UI toggles etc.)
|
||||||
self.settings: dict[str, Any] = {"hacs_enabled": False}
|
self.settings: dict[str, Any] = {"hacs_enabled": False, "favorite_repo_ids": []}
|
||||||
|
|
||||||
# Cached HACS metadata (display names/descriptions). Loaded from storage.
|
# Cached HACS metadata (display names/descriptions). Loaded from storage.
|
||||||
self._hacs_meta_fetched_at: int = 0
|
self._hacs_meta_fetched_at: int = 0
|
||||||
@@ -341,24 +348,52 @@ class BCSCore:
|
|||||||
|
|
||||||
def get_settings_public(self) -> dict[str, Any]:
|
def get_settings_public(self) -> dict[str, Any]:
|
||||||
"""Return UI-relevant settings (no I/O)."""
|
"""Return UI-relevant settings (no I/O)."""
|
||||||
|
favorite_repo_ids = self.settings.get("favorite_repo_ids") or []
|
||||||
|
if not isinstance(favorite_repo_ids, list):
|
||||||
|
favorite_repo_ids = []
|
||||||
return {
|
return {
|
||||||
"hacs_enabled": bool(self.settings.get("hacs_enabled", False)),
|
"hacs_enabled": bool(self.settings.get("hacs_enabled", False)),
|
||||||
|
"favorite_repo_ids": [str(x) for x in favorite_repo_ids if str(x).strip()],
|
||||||
}
|
}
|
||||||
|
|
||||||
async def set_settings(self, updates: dict[str, Any]) -> dict[str, Any]:
|
async def set_settings(self, updates: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Persist settings and apply them."""
|
"""Persist settings and apply them."""
|
||||||
safe_updates: dict[str, Any] = {}
|
safe_updates: dict[str, Any] = {}
|
||||||
|
reload_required = False
|
||||||
if "hacs_enabled" in (updates or {}):
|
if "hacs_enabled" in (updates or {}):
|
||||||
safe_updates["hacs_enabled"] = bool(updates.get("hacs_enabled"))
|
safe_updates["hacs_enabled"] = bool(updates.get("hacs_enabled"))
|
||||||
|
reload_required = True
|
||||||
|
if "favorite_repo_ids" in (updates or {}):
|
||||||
|
raw = updates.get("favorite_repo_ids") or []
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
raw = []
|
||||||
|
favorite_ids: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for item in raw:
|
||||||
|
rid = str(item or "").strip()
|
||||||
|
if not rid or rid in seen:
|
||||||
|
continue
|
||||||
|
seen.add(rid)
|
||||||
|
favorite_ids.append(rid)
|
||||||
|
safe_updates["favorite_repo_ids"] = favorite_ids
|
||||||
|
|
||||||
merged = await self.storage.set_settings(safe_updates)
|
merged = await self.storage.set_settings(safe_updates)
|
||||||
if isinstance(merged, dict):
|
if isinstance(merged, dict):
|
||||||
self.settings.update(merged)
|
self.settings.update(merged)
|
||||||
|
|
||||||
# Reload repo list after changing settings.
|
if reload_required:
|
||||||
await self.full_refresh(source="settings")
|
await self.full_refresh(source="settings")
|
||||||
|
else:
|
||||||
|
self.signal_updated()
|
||||||
return self.get_settings_public()
|
return self.get_settings_public()
|
||||||
|
|
||||||
|
def is_favorite_repo(self, repo_id: str) -> bool:
|
||||||
|
favorite_repo_ids = self.settings.get("favorite_repo_ids") or []
|
||||||
|
if not isinstance(favorite_repo_ids, list):
|
||||||
|
return False
|
||||||
|
target = str(repo_id or "").strip()
|
||||||
|
return bool(target) and target in [str(x).strip() for x in favorite_repo_ids]
|
||||||
|
|
||||||
async def refresh(self) -> None:
|
async def refresh(self) -> None:
|
||||||
index_repos, refresh_seconds = await self._load_index_repos()
|
index_repos, refresh_seconds = await self._load_index_repos()
|
||||||
self.refresh_seconds = refresh_seconds
|
self.refresh_seconds = refresh_seconds
|
||||||
@@ -1137,13 +1172,18 @@ class BCSCore:
|
|||||||
"latest_version": r.latest_version,
|
"latest_version": r.latest_version,
|
||||||
"latest_version_source": r.latest_version_source,
|
"latest_version_source": r.latest_version_source,
|
||||||
"category": r.meta_category,
|
"category": r.meta_category,
|
||||||
|
"category_key": self._repo_install_type(r),
|
||||||
|
"install_target": self._repo_install_target(r),
|
||||||
"meta_author": r.meta_author,
|
"meta_author": r.meta_author,
|
||||||
"meta_maintainer": r.meta_maintainer,
|
"meta_maintainer": r.meta_maintainer,
|
||||||
"meta_source": r.meta_source,
|
"meta_source": r.meta_source,
|
||||||
"installed": installed,
|
"installed": installed,
|
||||||
|
"install_type": str(inst.get("install_type") if isinstance(inst, dict) else self._repo_install_type(r)),
|
||||||
"installed_version": installed_version,
|
"installed_version": installed_version,
|
||||||
"installed_manifest_version": installed_manifest_version,
|
"installed_manifest_version": installed_manifest_version,
|
||||||
"installed_domains": installed_domains,
|
"installed_domains": installed_domains,
|
||||||
|
"installed_paths": list(inst.get("installed_paths") or []) if isinstance(inst, dict) else [],
|
||||||
|
"favorite": self.is_favorite_repo(r.id),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
@@ -1160,6 +1200,23 @@ class BCSCore:
|
|||||||
default_branch=repo.default_branch,
|
default_branch=repo.default_branch,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def fetch_release_notes_markdown(self, repo_id: str, ref: str | None = None) -> str | None:
|
||||||
|
repo = self.get_repo(repo_id)
|
||||||
|
if not repo:
|
||||||
|
return None
|
||||||
|
|
||||||
|
target_ref = (ref or "").strip() or (repo.latest_version or "").strip()
|
||||||
|
if not target_ref:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return await fetch_release_notes_markdown(
|
||||||
|
self.hass,
|
||||||
|
repo.url,
|
||||||
|
ref=target_ref,
|
||||||
|
provider=repo.provider,
|
||||||
|
github_token=self.config.github_token,
|
||||||
|
)
|
||||||
|
|
||||||
def _pick_ref_for_install(self, repo: RepoItem) -> str:
|
def _pick_ref_for_install(self, repo: RepoItem) -> str:
|
||||||
if repo.latest_version and str(repo.latest_version).strip():
|
if repo.latest_version and str(repo.latest_version).strip():
|
||||||
return str(repo.latest_version).strip()
|
return str(repo.latest_version).strip()
|
||||||
@@ -1167,6 +1224,32 @@ class BCSCore:
|
|||||||
return str(repo.default_branch).strip()
|
return str(repo.default_branch).strip()
|
||||||
return "main"
|
return "main"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_category_key(category: str | None) -> str:
|
||||||
|
raw = str(category or "").strip().lower()
|
||||||
|
if raw in ("integration", "integrations"):
|
||||||
|
return "integration"
|
||||||
|
if raw in ("blueprint", "blueprints"):
|
||||||
|
return "blueprint"
|
||||||
|
if raw in ("template", "templates"):
|
||||||
|
return "template"
|
||||||
|
if raw in ("lovelace", "dashboard", "dashboards", "lovelace design", "lovelace designs"):
|
||||||
|
return "lovelace"
|
||||||
|
return "integration"
|
||||||
|
|
||||||
|
def _repo_install_type(self, repo: RepoItem | None) -> str:
|
||||||
|
return self._normalize_category_key(getattr(repo, "meta_category", None))
|
||||||
|
|
||||||
|
def _repo_install_target(self, repo: RepoItem | None) -> str:
|
||||||
|
install_type = self._repo_install_type(repo)
|
||||||
|
if install_type == "blueprint":
|
||||||
|
return "/config/blueprints"
|
||||||
|
if install_type == "template":
|
||||||
|
return "/config"
|
||||||
|
if install_type == "lovelace":
|
||||||
|
return "/config"
|
||||||
|
return "/config/custom_components"
|
||||||
|
|
||||||
def _build_zip_url(self, repo_url: str, ref: str) -> str:
|
def _build_zip_url(self, repo_url: str, ref: str) -> str:
|
||||||
ref = (ref or "").strip()
|
ref = (ref or "").strip()
|
||||||
if not ref:
|
if not ref:
|
||||||
@@ -1233,6 +1316,18 @@ class BCSCore:
|
|||||||
return candidate
|
return candidate
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _find_blueprints_root(extract_root: Path) -> Path | None:
|
||||||
|
direct = extract_root / "blueprints"
|
||||||
|
if direct.exists() and direct.is_dir():
|
||||||
|
return direct
|
||||||
|
|
||||||
|
for child in extract_root.iterdir():
|
||||||
|
candidate = child / "blueprints"
|
||||||
|
if candidate.exists() and candidate.is_dir():
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
async def _ensure_backup_root(self) -> None:
|
async def _ensure_backup_root(self) -> None:
|
||||||
"""Create backup root directory if needed."""
|
"""Create backup root directory if needed."""
|
||||||
def _mkdir() -> None:
|
def _mkdir() -> None:
|
||||||
@@ -1240,6 +1335,11 @@ class BCSCore:
|
|||||||
|
|
||||||
await self.hass.async_add_executor_job(_mkdir)
|
await self.hass.async_add_executor_job(_mkdir)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _backup_repo_key(repo_id: str) -> str:
|
||||||
|
safe = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in str(repo_id or "").strip())
|
||||||
|
return safe or "repo"
|
||||||
|
|
||||||
def _build_backup_meta(self, repo_id: str, domain: str) -> dict[str, object]:
|
def _build_backup_meta(self, repo_id: str, domain: str) -> dict[str, object]:
|
||||||
"""Build metadata for backup folders so restores can recover the stored version."""
|
"""Build metadata for backup folders so restores can recover the stored version."""
|
||||||
inst = self.get_installed(repo_id) or {}
|
inst = self.get_installed(repo_id) or {}
|
||||||
@@ -1296,6 +1396,68 @@ class BCSCore:
|
|||||||
_LOGGER.info("BCS backup created: domain=%s path=%s", domain, backup_path)
|
_LOGGER.info("BCS backup created: domain=%s path=%s", domain, backup_path)
|
||||||
return backup_path
|
return backup_path
|
||||||
|
|
||||||
|
async def _backup_paths(
|
||||||
|
self,
|
||||||
|
repo_id: str,
|
||||||
|
relative_paths: list[str],
|
||||||
|
*,
|
||||||
|
meta: dict[str, object] | None = None,
|
||||||
|
) -> Path | None:
|
||||||
|
"""Backup arbitrary files under the Home Assistant config root."""
|
||||||
|
|
||||||
|
clean_paths = [str(p).strip().replace("\\", "/") for p in (relative_paths or []) if str(p).strip()]
|
||||||
|
if not clean_paths:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cfg_root = Path(self.hass.config.path(""))
|
||||||
|
existing: list[str] = []
|
||||||
|
for rel in clean_paths:
|
||||||
|
target = cfg_root / rel
|
||||||
|
if target.exists():
|
||||||
|
existing.append(rel)
|
||||||
|
if not existing:
|
||||||
|
return None
|
||||||
|
|
||||||
|
await self._ensure_backup_root()
|
||||||
|
|
||||||
|
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||||
|
repo_root = self._backup_root / "_content" / self._backup_repo_key(repo_id)
|
||||||
|
backup_path = repo_root / ts
|
||||||
|
|
||||||
|
def _do_backup() -> None:
|
||||||
|
repo_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
if backup_path.exists():
|
||||||
|
shutil.rmtree(backup_path, ignore_errors=True)
|
||||||
|
backup_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for rel in existing:
|
||||||
|
src = cfg_root / rel
|
||||||
|
dest = backup_path / rel
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if src.is_file():
|
||||||
|
shutil.copy2(src, dest)
|
||||||
|
elif src.is_dir():
|
||||||
|
shutil.copytree(src, dest, dirs_exist_ok=True)
|
||||||
|
|
||||||
|
if meta:
|
||||||
|
try:
|
||||||
|
meta_path = backup_path / BACKUP_META_FILENAME
|
||||||
|
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
backups = [p for p in repo_root.iterdir() if p.is_dir()]
|
||||||
|
backups.sort(key=lambda p: p.name, reverse=True)
|
||||||
|
for old in backups[self._backup_keep_per_domain :]:
|
||||||
|
shutil.rmtree(old, ignore_errors=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await self.hass.async_add_executor_job(_do_backup)
|
||||||
|
_LOGGER.info("BCS content backup created: repo_id=%s path=%s files=%s", repo_id, backup_path, len(existing))
|
||||||
|
return backup_path
|
||||||
|
|
||||||
async def _restore_domain_from_backup(self, domain: str, backup_path: Path) -> None:
|
async def _restore_domain_from_backup(self, domain: str, backup_path: Path) -> None:
|
||||||
"""Restore a domain folder from a backup."""
|
"""Restore a domain folder from a backup."""
|
||||||
dest_root = Path(self.hass.config.path("custom_components"))
|
dest_root = Path(self.hass.config.path("custom_components"))
|
||||||
@@ -1333,6 +1495,37 @@ class BCSCore:
|
|||||||
await self.hass.async_add_executor_job(_restore)
|
await self.hass.async_add_executor_job(_restore)
|
||||||
_LOGGER.info("BCS rollback applied: domain=%s from=%s", domain, backup_path)
|
_LOGGER.info("BCS rollback applied: domain=%s from=%s", domain, backup_path)
|
||||||
|
|
||||||
|
async def _restore_paths_from_backup(self, backup_path: Path, *, remove_targets: list[str] | None = None) -> None:
|
||||||
|
"""Restore arbitrary backed up files under the Home Assistant config root."""
|
||||||
|
|
||||||
|
cfg_root = Path(self.hass.config.path(""))
|
||||||
|
remove_list = [str(p).strip().replace("\\", "/") for p in (remove_targets or []) if str(p).strip()]
|
||||||
|
|
||||||
|
def _restore() -> None:
|
||||||
|
if not backup_path.exists() or not backup_path.is_dir():
|
||||||
|
return
|
||||||
|
|
||||||
|
for rel in remove_list:
|
||||||
|
target = cfg_root / rel
|
||||||
|
if target.exists():
|
||||||
|
if target.is_dir():
|
||||||
|
shutil.rmtree(target, ignore_errors=True)
|
||||||
|
elif target.is_file():
|
||||||
|
target.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
for src in backup_path.rglob("*"):
|
||||||
|
if not src.is_file():
|
||||||
|
continue
|
||||||
|
if src.name == BACKUP_META_FILENAME:
|
||||||
|
continue
|
||||||
|
rel = src.relative_to(backup_path)
|
||||||
|
dest = cfg_root / rel
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(src, dest)
|
||||||
|
|
||||||
|
await self.hass.async_add_executor_job(_restore)
|
||||||
|
_LOGGER.info("BCS content rollback applied: from=%s", backup_path)
|
||||||
|
|
||||||
async def list_repo_backups(self, repo_id: str) -> list[dict[str, Any]]:
|
async def list_repo_backups(self, repo_id: str) -> list[dict[str, Any]]:
|
||||||
"""List available backup sets for an installed repository.
|
"""List available backup sets for an installed repository.
|
||||||
|
|
||||||
@@ -1343,6 +1536,30 @@ class BCSCore:
|
|||||||
domains of the repository.
|
domains of the repository.
|
||||||
"""
|
"""
|
||||||
inst = self.get_installed(repo_id) or {}
|
inst = self.get_installed(repo_id) or {}
|
||||||
|
install_type = str(inst.get("install_type") or "integration").strip() or "integration"
|
||||||
|
if install_type == "blueprint":
|
||||||
|
repo_root = self._backup_root / "_content" / self._backup_repo_key(repo_id)
|
||||||
|
|
||||||
|
def _list_content() -> list[str]:
|
||||||
|
if not repo_root.exists() or not repo_root.is_dir():
|
||||||
|
return []
|
||||||
|
ids = [p.name for p in repo_root.iterdir() if p.is_dir()]
|
||||||
|
ids.sort(reverse=True)
|
||||||
|
return ids
|
||||||
|
|
||||||
|
ids = await self.hass.async_add_executor_job(_list_content)
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for bid in ids[: self._backup_keep_per_domain]:
|
||||||
|
label = self._format_backup_id(bid)
|
||||||
|
meta = await self._read_content_backup_meta(repo_id, bid)
|
||||||
|
ver = None
|
||||||
|
if isinstance(meta, dict):
|
||||||
|
ver = meta.get("installed_version") or meta.get("ref")
|
||||||
|
if ver:
|
||||||
|
label = f"{label} ({ver})"
|
||||||
|
items.append({"id": bid, "label": label, "complete": True, "domains": [], "installed_version": str(ver) if ver else None})
|
||||||
|
return items
|
||||||
|
|
||||||
domains = inst.get("domains") or []
|
domains = inst.get("domains") or []
|
||||||
if not isinstance(domains, list) or not domains:
|
if not isinstance(domains, list) or not domains:
|
||||||
return []
|
return []
|
||||||
@@ -1394,6 +1611,53 @@ class BCSCore:
|
|||||||
if not inst:
|
if not inst:
|
||||||
raise BCSInstallError("Repository is not installed")
|
raise BCSInstallError("Repository is not installed")
|
||||||
|
|
||||||
|
install_type = str(getattr(inst, "install_type", "integration") or "integration").strip() or "integration"
|
||||||
|
if install_type == "blueprint":
|
||||||
|
backup_path = self._backup_root / "_content" / self._backup_repo_key(repo_id) / backup_id
|
||||||
|
if not backup_path.exists() or not backup_path.is_dir():
|
||||||
|
raise BCSInstallError("Selected backup is not available")
|
||||||
|
|
||||||
|
installed_paths = [str(p).strip() for p in (getattr(inst, "installed_paths", None) or []) if str(p).strip()]
|
||||||
|
|
||||||
|
async with self._install_lock:
|
||||||
|
_LOGGER.info("BCS restore started: repo_id=%s backup_id=%s type=blueprint", repo_id, backup_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._backup_paths(repo_id, installed_paths)
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.debug("BCS pre-restore content backup failed for repo_id=%s", repo_id, exc_info=True)
|
||||||
|
|
||||||
|
await self._restore_paths_from_backup(backup_path, remove_targets=installed_paths)
|
||||||
|
|
||||||
|
restored_meta = await self._read_content_backup_meta(repo_id, backup_id)
|
||||||
|
restored_version: str | None = None
|
||||||
|
if isinstance(restored_meta, dict):
|
||||||
|
rv = restored_meta.get("installed_version") or restored_meta.get("ref")
|
||||||
|
if rv is not None and str(rv).strip():
|
||||||
|
restored_version = str(rv).strip()
|
||||||
|
if not restored_version:
|
||||||
|
restored_version = f"restored:{backup_id}"
|
||||||
|
|
||||||
|
repo = self.get_repo(repo_id)
|
||||||
|
repo_url = getattr(repo, "url", None) or ""
|
||||||
|
|
||||||
|
await self.storage.set_installed_repo(
|
||||||
|
repo_id=repo_id,
|
||||||
|
url=repo_url,
|
||||||
|
domains=[],
|
||||||
|
installed_version=restored_version,
|
||||||
|
installed_manifest_version=None,
|
||||||
|
ref=restored_version,
|
||||||
|
install_type="blueprint",
|
||||||
|
installed_paths=installed_paths,
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._refresh_installed_cache()
|
||||||
|
self.signal_updated()
|
||||||
|
|
||||||
|
_LOGGER.info("BCS restore complete: repo_id=%s backup_id=%s type=blueprint", repo_id, backup_id)
|
||||||
|
return {"ok": True, "repo_id": repo_id, "backup_id": backup_id, "domains": [], "installed_paths": installed_paths, "restored_version": restored_version, "restart_required": False}
|
||||||
|
|
||||||
domains = inst.get("domains") or []
|
domains = inst.get("domains") or []
|
||||||
if not isinstance(domains, list) or not domains:
|
if not isinstance(domains, list) or not domains:
|
||||||
raise BCSInstallError("No installed domains found")
|
raise BCSInstallError("No installed domains found")
|
||||||
@@ -1490,6 +1754,17 @@ class BCSCore:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def _read_content_backup_meta(self, repo_id: str, backup_id: str) -> dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
p = self._backup_root / "_content" / self._backup_repo_key(repo_id) / backup_id / BACKUP_META_FILENAME
|
||||||
|
if not p.exists():
|
||||||
|
return None
|
||||||
|
txt = await self.hass.async_add_executor_job(p.read_text, 'utf-8')
|
||||||
|
data = json.loads(txt)
|
||||||
|
return data if isinstance(data, dict) else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _read_backup_manifest_version(self, domain: str, backup_id: str) -> str | None:
|
async def _read_backup_manifest_version(self, domain: str, backup_id: str) -> str | None:
|
||||||
"""Best-effort: read manifest.json version from a legacy backup (no metadata)."""
|
"""Best-effort: read manifest.json version from a legacy backup (no metadata)."""
|
||||||
@@ -1587,15 +1862,25 @@ class BCSCore:
|
|||||||
|
|
||||||
for it in items:
|
for it in items:
|
||||||
domains = [str(d) for d in (it.domains or []) if str(d).strip()]
|
domains = [str(d) for d in (it.domains or []) if str(d).strip()]
|
||||||
|
install_type = str(getattr(it, "install_type", "integration") or "integration").strip() or "integration"
|
||||||
|
installed_paths = [str(p) for p in (getattr(it, "installed_paths", None) or []) if str(p).strip()]
|
||||||
|
|
||||||
# A repo is considered "present" if at least one of its domains
|
# A repo is considered "present" if at least one of its domains
|
||||||
# exists and contains a manifest.json.
|
# exists and contains a manifest.json.
|
||||||
present = False
|
present = False
|
||||||
for d in domains:
|
if install_type == "integration":
|
||||||
p = cc_root / d
|
for d in domains:
|
||||||
if p.is_dir() and (p / "manifest.json").exists():
|
p = cc_root / d
|
||||||
present = True
|
if p.is_dir() and (p / "manifest.json").exists():
|
||||||
break
|
present = True
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
cfg_root = Path(self.hass.config.path(""))
|
||||||
|
for rel in installed_paths:
|
||||||
|
p = cfg_root / rel
|
||||||
|
if p.exists():
|
||||||
|
present = True
|
||||||
|
break
|
||||||
|
|
||||||
if not present:
|
if not present:
|
||||||
to_remove.append(it.repo_id)
|
to_remove.append(it.repo_id)
|
||||||
@@ -1604,6 +1889,8 @@ class BCSCore:
|
|||||||
cache[it.repo_id] = {
|
cache[it.repo_id] = {
|
||||||
"installed": True,
|
"installed": True,
|
||||||
"domains": domains,
|
"domains": domains,
|
||||||
|
"install_type": install_type,
|
||||||
|
"installed_paths": installed_paths,
|
||||||
"installed_version": it.installed_version,
|
"installed_version": it.installed_version,
|
||||||
"installed_manifest_version": it.installed_manifest_version,
|
"installed_manifest_version": it.installed_manifest_version,
|
||||||
"ref": it.ref,
|
"ref": it.ref,
|
||||||
@@ -1639,24 +1926,50 @@ class BCSCore:
|
|||||||
if path.exists() and path.is_dir():
|
if path.exists() and path.is_dir():
|
||||||
shutil.rmtree(path, ignore_errors=True)
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
|
|
||||||
for domain in inst.domains:
|
def _remove_file(path: Path) -> None:
|
||||||
d = str(domain).strip()
|
if path.exists() and path.is_file():
|
||||||
if not d:
|
path.unlink(missing_ok=True)
|
||||||
continue
|
|
||||||
target = cc_root / d
|
def _prune_empty_parents(path: Path, stop_at: Path) -> None:
|
||||||
await self.hass.async_add_executor_job(_remove_dir, target)
|
cur = path.parent
|
||||||
removed.append(d)
|
while cur != stop_at and str(cur).startswith(str(stop_at)):
|
||||||
|
try:
|
||||||
|
if any(cur.iterdir()):
|
||||||
|
break
|
||||||
|
cur.rmdir()
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
cur = cur.parent
|
||||||
|
|
||||||
|
install_type = str(getattr(inst, "install_type", "integration") or "integration").strip() or "integration"
|
||||||
|
|
||||||
|
if install_type == "integration":
|
||||||
|
for domain in inst.domains:
|
||||||
|
d = str(domain).strip()
|
||||||
|
if not d:
|
||||||
|
continue
|
||||||
|
target = cc_root / d
|
||||||
|
await self.hass.async_add_executor_job(_remove_dir, target)
|
||||||
|
removed.append(d)
|
||||||
|
elif install_type == "blueprint":
|
||||||
|
cfg_root = Path(self.hass.config.path(""))
|
||||||
|
blueprints_root = Path(self.hass.config.path("blueprints"))
|
||||||
|
for rel in [str(p).strip() for p in (inst.installed_paths or []) if str(p).strip()]:
|
||||||
|
target = cfg_root / rel
|
||||||
|
await self.hass.async_add_executor_job(_remove_file, target)
|
||||||
|
await self.hass.async_add_executor_job(_prune_empty_parents, target, blueprints_root)
|
||||||
|
removed.append(rel)
|
||||||
|
|
||||||
await self.storage.remove_installed_repo(repo_id)
|
await self.storage.remove_installed_repo(repo_id)
|
||||||
await self._refresh_installed_cache()
|
await self._refresh_installed_cache()
|
||||||
|
|
||||||
# Show restart required in Settings.
|
# Show restart required in Settings.
|
||||||
if removed:
|
if removed and install_type == "integration":
|
||||||
self._mark_restart_required()
|
self._mark_restart_required()
|
||||||
|
|
||||||
_LOGGER.info("BCS uninstall complete: repo_id=%s removed_domains=%s", repo_id, removed)
|
_LOGGER.info("BCS uninstall complete: repo_id=%s removed_domains=%s", repo_id, removed)
|
||||||
self.signal_updated()
|
self.signal_updated()
|
||||||
return {"ok": True, "repo_id": repo_id, "removed": removed, "restart_required": bool(removed)}
|
return {"ok": True, "repo_id": repo_id, "removed": removed, "restart_required": bool(removed) if install_type == "integration" else False}
|
||||||
|
|
||||||
async def install_repo(self, repo_id: str, *, version: str | None = None) -> dict[str, Any]:
|
async def install_repo(self, repo_id: str, *, version: str | None = None) -> dict[str, Any]:
|
||||||
repo = self.get_repo(repo_id)
|
repo = self.get_repo(repo_id)
|
||||||
@@ -1671,7 +1984,10 @@ class BCSCore:
|
|||||||
_LOGGER.info("BCS install started: repo_id=%s ref=%s zip_url=%s", repo_id, ref, zip_url)
|
_LOGGER.info("BCS install started: repo_id=%s ref=%s zip_url=%s", repo_id, ref, zip_url)
|
||||||
|
|
||||||
installed_domains: list[str] = []
|
installed_domains: list[str] = []
|
||||||
|
installed_paths: list[str] = []
|
||||||
backups: dict[str, Path] = {}
|
backups: dict[str, Path] = {}
|
||||||
|
content_backup: Path | None = None
|
||||||
|
install_type = self._repo_install_type(repo)
|
||||||
|
|
||||||
inst_before = self.get_installed(repo_id) or {}
|
inst_before = self.get_installed(repo_id) or {}
|
||||||
backup_meta = {
|
backup_meta = {
|
||||||
@@ -1692,57 +2008,97 @@ class BCSCore:
|
|||||||
await self._download_zip(zip_url, zip_path)
|
await self._download_zip(zip_url, zip_path)
|
||||||
await self._extract_zip(zip_path, extract_dir)
|
await self._extract_zip(zip_path, extract_dir)
|
||||||
|
|
||||||
cc_root = self._find_custom_components_root(extract_dir)
|
if install_type == "blueprint":
|
||||||
if not cc_root:
|
blueprints_root = self._find_blueprints_root(extract_dir)
|
||||||
raise BCSInstallError("custom_components folder not found in repository ZIP")
|
if not blueprints_root:
|
||||||
|
raise BCSInstallError("blueprints folder not found in repository ZIP")
|
||||||
|
|
||||||
dest_root = Path(self.hass.config.path("custom_components"))
|
target_root = Path(self.hass.config.path("blueprints"))
|
||||||
|
planned_paths: list[str] = []
|
||||||
|
|
||||||
for domain_dir in cc_root.iterdir():
|
for src in blueprints_root.rglob("*"):
|
||||||
if not domain_dir.is_dir():
|
if not src.is_file():
|
||||||
continue
|
continue
|
||||||
manifest = domain_dir / "manifest.json"
|
rel = src.relative_to(blueprints_root)
|
||||||
if not manifest.exists():
|
planned_paths.append(str(Path("blueprints") / rel).replace("\\", "/"))
|
||||||
continue
|
|
||||||
|
|
||||||
domain = domain_dir.name
|
if not planned_paths:
|
||||||
target = dest_root / domain
|
raise BCSInstallError("No blueprint files found under blueprints/")
|
||||||
|
|
||||||
# Backup only if we are going to overwrite an existing domain.
|
m = dict(backup_meta)
|
||||||
if target.exists() and target.is_dir():
|
m["install_type"] = "blueprint"
|
||||||
m = dict(backup_meta)
|
m["installed_paths"] = planned_paths
|
||||||
m["domain"] = domain
|
content_backup = await self._backup_paths(repo_id, planned_paths, meta=m)
|
||||||
bkp = await self._backup_domain(domain, meta=m)
|
|
||||||
if bkp:
|
|
||||||
backups[domain] = bkp
|
|
||||||
else:
|
|
||||||
created_new.add(domain)
|
|
||||||
|
|
||||||
await self._copy_domain_dir(domain_dir, domain)
|
def _copy_blueprints() -> list[str]:
|
||||||
installed_domains.append(domain)
|
copied: list[str] = []
|
||||||
|
target_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
for src in blueprints_root.rglob("*"):
|
||||||
|
if not src.is_file():
|
||||||
|
continue
|
||||||
|
rel = src.relative_to(blueprints_root)
|
||||||
|
dest = target_root / rel
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(src, dest)
|
||||||
|
copied.append(str(Path("blueprints") / rel).replace("\\", "/"))
|
||||||
|
return copied
|
||||||
|
|
||||||
if not installed_domains:
|
installed_paths = await self.hass.async_add_executor_job(_copy_blueprints)
|
||||||
raise BCSInstallError("No integrations found under custom_components/ (missing manifest.json)")
|
else:
|
||||||
|
cc_root = self._find_custom_components_root(extract_dir)
|
||||||
|
if not cc_root:
|
||||||
|
raise BCSInstallError("custom_components folder not found in repository ZIP")
|
||||||
|
|
||||||
installed_manifest_version = await self._read_installed_manifest_version(installed_domains[0])
|
dest_root = Path(self.hass.config.path("custom_components"))
|
||||||
|
|
||||||
|
for domain_dir in cc_root.iterdir():
|
||||||
|
if not domain_dir.is_dir():
|
||||||
|
continue
|
||||||
|
manifest = domain_dir / "manifest.json"
|
||||||
|
if not manifest.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
domain = domain_dir.name
|
||||||
|
target = dest_root / domain
|
||||||
|
|
||||||
|
# Backup only if we are going to overwrite an existing domain.
|
||||||
|
if target.exists() and target.is_dir():
|
||||||
|
m = dict(backup_meta)
|
||||||
|
m["domain"] = domain
|
||||||
|
bkp = await self._backup_domain(domain, meta=m)
|
||||||
|
if bkp:
|
||||||
|
backups[domain] = bkp
|
||||||
|
else:
|
||||||
|
created_new.add(domain)
|
||||||
|
|
||||||
|
await self._copy_domain_dir(domain_dir, domain)
|
||||||
|
installed_domains.append(domain)
|
||||||
|
|
||||||
|
if not installed_domains:
|
||||||
|
raise BCSInstallError("No integrations found under custom_components/ (missing manifest.json)")
|
||||||
|
|
||||||
|
installed_manifest_version = await self._read_installed_manifest_version(installed_domains[0]) if installed_domains else None
|
||||||
installed_version = ref
|
installed_version = ref
|
||||||
|
|
||||||
await self.storage.set_installed_repo(
|
await self.storage.set_installed_repo(
|
||||||
repo_id=repo_id,
|
repo_id=repo_id,
|
||||||
url=repo.url,
|
url=repo.url,
|
||||||
domains=installed_domains,
|
domains=installed_domains,
|
||||||
|
install_type=install_type,
|
||||||
|
installed_paths=installed_paths,
|
||||||
installed_version=installed_version,
|
installed_version=installed_version,
|
||||||
installed_manifest_version=installed_manifest_version,
|
installed_manifest_version=installed_manifest_version,
|
||||||
ref=ref,
|
ref=ref,
|
||||||
)
|
)
|
||||||
await self._refresh_installed_cache()
|
await self._refresh_installed_cache()
|
||||||
|
|
||||||
self._mark_restart_required()
|
if install_type == "integration":
|
||||||
|
self._mark_restart_required()
|
||||||
|
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"BCS install complete: repo_id=%s domains=%s installed_ref=%s manifest_version=%s",
|
"BCS install complete: repo_id=%s domains=%s installed_ref=%s manifest_version=%s",
|
||||||
repo_id,
|
repo_id,
|
||||||
installed_domains,
|
installed_domains if installed_domains else installed_paths,
|
||||||
installed_version,
|
installed_version,
|
||||||
installed_manifest_version,
|
installed_manifest_version,
|
||||||
)
|
)
|
||||||
@@ -1751,9 +2107,11 @@ class BCSCore:
|
|||||||
"ok": True,
|
"ok": True,
|
||||||
"repo_id": repo_id,
|
"repo_id": repo_id,
|
||||||
"domains": installed_domains,
|
"domains": installed_domains,
|
||||||
|
"installed_paths": installed_paths,
|
||||||
|
"install_type": install_type,
|
||||||
"installed_version": installed_version,
|
"installed_version": installed_version,
|
||||||
"installed_manifest_version": installed_manifest_version,
|
"installed_manifest_version": installed_manifest_version,
|
||||||
"restart_required": True,
|
"restart_required": True if install_type == "integration" else False,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1769,6 +2127,12 @@ class BCSCore:
|
|||||||
except Exception:
|
except Exception:
|
||||||
_LOGGER.debug("BCS rollback failed for domain=%s", domain, exc_info=True)
|
_LOGGER.debug("BCS rollback failed for domain=%s", domain, exc_info=True)
|
||||||
|
|
||||||
|
if content_backup is not None:
|
||||||
|
try:
|
||||||
|
await self._restore_paths_from_backup(content_backup, remove_targets=installed_paths)
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.debug("BCS content rollback failed for repo_id=%s", repo_id, exc_info=True)
|
||||||
|
|
||||||
# Remove newly created domains if the install did not complete.
|
# Remove newly created domains if the install did not complete.
|
||||||
for domain in created_new:
|
for domain in created_new:
|
||||||
try:
|
try:
|
||||||
@@ -1790,4 +2154,4 @@ class BCSCore:
|
|||||||
return await self.install_repo(repo_id, version=version)
|
return await self.install_repo(repo_id, version=version)
|
||||||
|
|
||||||
async def request_restart(self) -> None:
|
async def request_restart(self) -> None:
|
||||||
await self.hass.services.async_call("homeassistant", "restart", {}, blocking=False)
|
await self.hass.services.async_call("homeassistant", "restart", {}, blocking=False)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"domain": "bahmcloud_store",
|
"domain": "bahmcloud_store",
|
||||||
"name": "Bahmcloud Store",
|
"name": "Bahmcloud Store",
|
||||||
"version": "0.7.2",
|
"version": "0.7.6",
|
||||||
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
|
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"platforms": ["update"],
|
"platforms": ["update"],
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"codeowners": ["@bahmcloud"],
|
"codeowners": ["@bahmcloud"],
|
||||||
"iot_class": "local_polling"
|
"iot_class": "local_polling"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
|
|
||||||
// HACS toggle (settings)
|
// HACS toggle (settings)
|
||||||
this._hacsEnabled = false;
|
this._hacsEnabled = false;
|
||||||
|
this._favoriteRepoIds = [];
|
||||||
|
|
||||||
this._detailRepoId = null;
|
this._detailRepoId = null;
|
||||||
this._detailRepo = null;
|
this._detailRepo = null;
|
||||||
@@ -56,6 +57,10 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
this._versionsCache = {}; // repo_id -> [{ref,label,source}, ...]
|
this._versionsCache = {}; // repo_id -> [{ref,label,source}, ...]
|
||||||
this._versionsLoadingRepoId = null;
|
this._versionsLoadingRepoId = null;
|
||||||
this._selectedVersionByRepoId = {}; // repo_id -> ref ("" means latest)
|
this._selectedVersionByRepoId = {}; // repo_id -> ref ("" means latest)
|
||||||
|
this._releaseNotesLoading = false;
|
||||||
|
this._releaseNotesText = null;
|
||||||
|
this._releaseNotesHtml = null;
|
||||||
|
this._releaseNotesError = null;
|
||||||
|
|
||||||
// History handling (mobile back button should go back to list, not exit panel)
|
// History handling (mobile back button should go back to list, not exit panel)
|
||||||
this._historyBound = false;
|
this._historyBound = false;
|
||||||
@@ -117,9 +122,15 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
|
|
||||||
// Persistent settings (e.g. HACS toggle)
|
// Persistent settings (e.g. HACS toggle)
|
||||||
this._hacsEnabled = !!data?.settings?.hacs_enabled;
|
this._hacsEnabled = !!data?.settings?.hacs_enabled;
|
||||||
|
this._favoriteRepoIds = Array.isArray(data?.settings?.favorite_repo_ids)
|
||||||
|
? data.settings.favorite_repo_ids.map((x) => String(x))
|
||||||
|
: [];
|
||||||
|
|
||||||
// Sync settings from backend (e.g. HACS toggle)
|
// Sync settings from backend (e.g. HACS toggle)
|
||||||
this._hacsEnabled = !!data?.settings?.hacs_enabled;
|
this._hacsEnabled = !!data?.settings?.hacs_enabled;
|
||||||
|
this._favoriteRepoIds = Array.isArray(data?.settings?.favorite_repo_ids)
|
||||||
|
? data.settings.favorite_repo_ids.map((x) => String(x))
|
||||||
|
: [];
|
||||||
|
|
||||||
if (this._view === "detail" && this._detailRepoId && Array.isArray(data?.repos)) {
|
if (this._view === "detail" && this._detailRepoId && Array.isArray(data?.repos)) {
|
||||||
const fresh = data.repos.find((r) => this._safeId(r?.id) === this._detailRepoId);
|
const fresh = data.repos.find((r) => this._safeId(r?.id) === this._detailRepoId);
|
||||||
@@ -139,6 +150,9 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
const resp = await this._hass.callApi("post", "bcs/settings", updates || {});
|
const resp = await this._hass.callApi("post", "bcs/settings", updates || {});
|
||||||
if (resp?.ok) {
|
if (resp?.ok) {
|
||||||
this._hacsEnabled = !!resp?.settings?.hacs_enabled;
|
this._hacsEnabled = !!resp?.settings?.hacs_enabled;
|
||||||
|
this._favoriteRepoIds = Array.isArray(resp?.settings?.favorite_repo_ids)
|
||||||
|
? resp.settings.favorite_repo_ids.map((x) => String(x))
|
||||||
|
: [];
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Do not fail UI for settings.
|
// Do not fail UI for settings.
|
||||||
@@ -192,7 +206,9 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
this._error = this._safeText(resp?.message) || "Install failed.";
|
this._error = this._safeText(resp?.message) || "Install failed.";
|
||||||
} else {
|
} else {
|
||||||
this._restartRequired = !!resp.restart_required;
|
this._restartRequired = !!resp.restart_required;
|
||||||
this._lastActionMsg = "Installation finished. Restart required.";
|
this._lastActionMsg = resp?.restart_required
|
||||||
|
? "Installation finished. Restart required."
|
||||||
|
: "Installation finished. No restart required.";
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this._error = e?.message ? String(e.message) : String(e);
|
this._error = e?.message ? String(e.message) : String(e);
|
||||||
@@ -224,7 +240,9 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
this._error = this._safeText(resp?.message) || "Update failed.";
|
this._error = this._safeText(resp?.message) || "Update failed.";
|
||||||
} else {
|
} else {
|
||||||
this._restartRequired = !!resp.restart_required;
|
this._restartRequired = !!resp.restart_required;
|
||||||
this._lastActionMsg = "Update finished. Restart required.";
|
this._lastActionMsg = resp?.restart_required
|
||||||
|
? "Update finished. Restart required."
|
||||||
|
: "Update finished. No restart required.";
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this._error = e?.message ? String(e.message) : String(e);
|
this._error = e?.message ? String(e.message) : String(e);
|
||||||
@@ -239,7 +257,11 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
if (!repoId) return;
|
if (!repoId) return;
|
||||||
if (this._installingRepoId || this._updatingRepoId || this._uninstallingRepoId) return;
|
if (this._installingRepoId || this._updatingRepoId || this._uninstallingRepoId) return;
|
||||||
|
|
||||||
const ok = window.confirm("Really uninstall this repository? This will remove its files from /config/custom_components and requires a restart.");
|
const details = this._getInstallTypeDetails(repoId);
|
||||||
|
const uninstallText = details.restartRequired
|
||||||
|
? `Really uninstall this repository? This will remove its files from ${details.targetPath} and requires a restart.`
|
||||||
|
: `Really uninstall this repository? This will remove its files from ${details.targetPath}. No restart is required.`;
|
||||||
|
const ok = window.confirm(uninstallText);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
this._uninstallingRepoId = repoId;
|
this._uninstallingRepoId = repoId;
|
||||||
@@ -253,7 +275,9 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
this._error = this._safeText(resp?.message) || "Uninstall failed.";
|
this._error = this._safeText(resp?.message) || "Uninstall failed.";
|
||||||
} else {
|
} else {
|
||||||
this._restartRequired = !!resp.restart_required;
|
this._restartRequired = !!resp.restart_required;
|
||||||
this._lastActionMsg = "Uninstall finished. Restart required.";
|
this._lastActionMsg = resp?.restart_required
|
||||||
|
? "Uninstall finished. Restart required."
|
||||||
|
: "Uninstall finished. No restart required.";
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this._error = e?.message ? String(e.message) : String(e);
|
this._error = e?.message ? String(e.message) : String(e);
|
||||||
@@ -323,7 +347,11 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ok = window.confirm("Restore selected backup? This will overwrite the installed files under /config/custom_components and requires a restart.");
|
const details = this._getInstallTypeDetails(this._restoreRepoId);
|
||||||
|
const restoreText = details.restartRequired
|
||||||
|
? `Restore selected backup? This will overwrite the installed files under ${details.targetPath} and requires a restart.`
|
||||||
|
: `Restore selected backup? This will overwrite the installed files under ${details.targetPath}. No restart is required.`;
|
||||||
|
const ok = window.confirm(restoreText);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
this._restoring = true;
|
this._restoring = true;
|
||||||
@@ -336,7 +364,9 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
this._restoreError = this._safeText(resp?.message) || "Restore failed.";
|
this._restoreError = this._safeText(resp?.message) || "Restore failed.";
|
||||||
} else {
|
} else {
|
||||||
this._restartRequired = !!resp.restart_required;
|
this._restartRequired = !!resp.restart_required;
|
||||||
this._lastActionMsg = "Restore finished. Restart required.";
|
this._lastActionMsg = resp?.restart_required
|
||||||
|
? "Restore finished. Restart required."
|
||||||
|
: "Restore finished. No restart required.";
|
||||||
this._closeRestore();
|
this._closeRestore();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -442,6 +472,10 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
this._readmeError = null;
|
this._readmeError = null;
|
||||||
this._readmeExpanded = false;
|
this._readmeExpanded = false;
|
||||||
this._readmeCanToggle = false;
|
this._readmeCanToggle = false;
|
||||||
|
this._releaseNotesLoading = false;
|
||||||
|
this._releaseNotesText = null;
|
||||||
|
this._releaseNotesHtml = null;
|
||||||
|
this._releaseNotesError = null;
|
||||||
|
|
||||||
// Versions dropdown
|
// Versions dropdown
|
||||||
if (!(repoId in this._selectedVersionByRepoId)) {
|
if (!(repoId in this._selectedVersionByRepoId)) {
|
||||||
@@ -452,6 +486,7 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
this._loadRepoDetails(repoId);
|
this._loadRepoDetails(repoId);
|
||||||
this._loadReadme(repoId);
|
this._loadReadme(repoId);
|
||||||
this._loadVersions(repoId);
|
this._loadVersions(repoId);
|
||||||
|
this._loadReleaseNotes(repoId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -499,6 +534,41 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _loadReleaseNotes(repoId) {
|
||||||
|
if (!this._hass || !repoId) return;
|
||||||
|
|
||||||
|
this._releaseNotesLoading = true;
|
||||||
|
this._releaseNotesText = null;
|
||||||
|
this._releaseNotesHtml = null;
|
||||||
|
this._releaseNotesError = null;
|
||||||
|
this._update();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sel = this._safeText(this._selectedVersionByRepoId?.[repoId] || "").trim();
|
||||||
|
const qv = sel ? `&ref=${encodeURIComponent(sel)}` : "";
|
||||||
|
const resp = await this._hass.callApi(
|
||||||
|
"get",
|
||||||
|
`bcs/release_notes?repo_id=${encodeURIComponent(repoId)}${qv}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (resp?.ok && typeof resp.release_notes === "string" && resp.release_notes.trim()) {
|
||||||
|
this._releaseNotesText = resp.release_notes;
|
||||||
|
this._releaseNotesHtml =
|
||||||
|
typeof resp.html === "string" && resp.html.trim() ? resp.html : null;
|
||||||
|
} else {
|
||||||
|
this._releaseNotesError =
|
||||||
|
this._safeText(resp?.message) || "Release notes not available for this version.";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this._releaseNotesError = e?.message
|
||||||
|
? String(e.message)
|
||||||
|
: "Release notes not available for this version.";
|
||||||
|
} finally {
|
||||||
|
this._releaseNotesLoading = false;
|
||||||
|
this._update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async _loadReadme(repoId) {
|
async _loadReadme(repoId) {
|
||||||
if (!this._hass) return;
|
if (!this._hass) return;
|
||||||
this._readmeLoading = true;
|
this._readmeLoading = true;
|
||||||
@@ -922,6 +992,58 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
return v === true;
|
return v === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_isFavoriteRepo(repoId) {
|
||||||
|
const id = this._safeId(repoId);
|
||||||
|
return !!id && Array.isArray(this._favoriteRepoIds) && this._favoriteRepoIds.includes(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
_getRepoById(repoId) {
|
||||||
|
const id = this._safeId(repoId);
|
||||||
|
if (!id || !Array.isArray(this._data?.repos)) return null;
|
||||||
|
return this._data.repos.find((r) => this._safeId(r?.id) === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_getInstallTypeDetails(repoOrId) {
|
||||||
|
const repo = typeof repoOrId === "string" ? this._getRepoById(repoOrId) : repoOrId;
|
||||||
|
const installType = this._safeText(repo?.install_type) || "integration";
|
||||||
|
if (installType === "blueprint") {
|
||||||
|
return { installType, targetPath: "/config/blueprints", restartRequired: false };
|
||||||
|
}
|
||||||
|
if (installType === "template") {
|
||||||
|
return { installType, targetPath: "/config", restartRequired: false };
|
||||||
|
}
|
||||||
|
if (installType === "lovelace") {
|
||||||
|
return { installType, targetPath: "/config/www", restartRequired: false };
|
||||||
|
}
|
||||||
|
return { installType: "integration", targetPath: "/config/custom_components", restartRequired: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async _toggleFavorite(repoId) {
|
||||||
|
if (!this._hass || !repoId) return;
|
||||||
|
|
||||||
|
const id = this._safeId(repoId);
|
||||||
|
const current = Array.isArray(this._favoriteRepoIds) ? this._favoriteRepoIds.slice() : [];
|
||||||
|
const next = current.includes(id)
|
||||||
|
? current.filter((x) => x !== id)
|
||||||
|
: current.concat([id]);
|
||||||
|
|
||||||
|
this._favoriteRepoIds = next;
|
||||||
|
|
||||||
|
if (Array.isArray(this._data?.repos)) {
|
||||||
|
this._data.repos = this._data.repos.map((r) => {
|
||||||
|
if (this._safeId(r?.id) !== id) return r;
|
||||||
|
return { ...r, favorite: next.includes(id) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this._detailRepo && this._safeId(this._detailRepo?.id) === id) {
|
||||||
|
this._detailRepo = { ...this._detailRepo, favorite: next.includes(id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
this._update();
|
||||||
|
await this._setSettings({ favorite_repo_ids: next });
|
||||||
|
await this._load();
|
||||||
|
}
|
||||||
|
|
||||||
_renderStore() {
|
_renderStore() {
|
||||||
const repos = Array.isArray(this._data.repos) ? this._data.repos : [];
|
const repos = Array.isArray(this._data.repos) ? this._data.repos : [];
|
||||||
|
|
||||||
@@ -946,11 +1068,13 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
const installed = this._asBoolStrict(r?.installed);
|
const installed = this._asBoolStrict(r?.installed);
|
||||||
const installedVersion = this._safeText(r?.installed_version);
|
const installedVersion = this._safeText(r?.installed_version);
|
||||||
const updateAvailable = installed && !!latest && (!installedVersion || latest !== installedVersion);
|
const updateAvailable = installed && !!latest && (!installedVersion || latest !== installedVersion);
|
||||||
|
const favorite = this._asBoolStrict(r?.favorite) || this._isFavoriteRepo(r?.id);
|
||||||
|
|
||||||
if (this._filter === "installed" && !installed) return false;
|
if (this._filter === "installed" && !installed) return false;
|
||||||
if (this._filter === "not_installed" && installed) return false;
|
if (this._filter === "not_installed" && installed) return false;
|
||||||
if (this._filter === "updates" && !updateAvailable) return false;
|
if (this._filter === "updates" && !updateAvailable) return false;
|
||||||
if (this._filter === "custom" && r?.source !== "custom") return false;
|
if (this._filter === "custom" && r?.source !== "custom") return false;
|
||||||
|
if (this._filter === "favorites" && !favorite) return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
@@ -962,16 +1086,22 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
const ainstalled = this._asBoolStrict(a?.installed);
|
const ainstalled = this._asBoolStrict(a?.installed);
|
||||||
const ainstalledVersion = this._safeText(a?.installed_version);
|
const ainstalledVersion = this._safeText(a?.installed_version);
|
||||||
const aupdate = ainstalled && !!alatest && (!ainstalledVersion || alatest !== ainstalledVersion);
|
const aupdate = ainstalled && !!alatest && (!ainstalledVersion || alatest !== ainstalledVersion);
|
||||||
|
const afavorite = this._asBoolStrict(a?.favorite) || this._isFavoriteRepo(a?.id);
|
||||||
|
|
||||||
const blatest = this._safeText(b?.latest_version);
|
const blatest = this._safeText(b?.latest_version);
|
||||||
const binstalled = this._asBoolStrict(b?.installed);
|
const binstalled = this._asBoolStrict(b?.installed);
|
||||||
const binstalledVersion = this._safeText(b?.installed_version);
|
const binstalledVersion = this._safeText(b?.installed_version);
|
||||||
const bupdate = binstalled && !!blatest && (!binstalledVersion || blatest !== binstalledVersion);
|
const bupdate = binstalled && !!blatest && (!binstalledVersion || blatest !== binstalledVersion);
|
||||||
|
const bfavorite = this._asBoolStrict(b?.favorite) || this._isFavoriteRepo(b?.id);
|
||||||
|
|
||||||
if (this._sort === "updates_first") {
|
if (this._sort === "updates_first") {
|
||||||
if (aupdate !== bupdate) return aupdate ? -1 : 1;
|
if (aupdate !== bupdate) return aupdate ? -1 : 1;
|
||||||
return an.localeCompare(bn);
|
return an.localeCompare(bn);
|
||||||
}
|
}
|
||||||
|
if (this._sort === "favorites_first") {
|
||||||
|
if (afavorite !== bfavorite) return afavorite ? -1 : 1;
|
||||||
|
return an.localeCompare(bn);
|
||||||
|
}
|
||||||
if (this._sort === "installed_first") {
|
if (this._sort === "installed_first") {
|
||||||
if (ainstalled !== binstalled) return ainstalled ? -1 : 1;
|
if (ainstalled !== binstalled) return ainstalled ? -1 : 1;
|
||||||
return an.localeCompare(bn);
|
return an.localeCompare(bn);
|
||||||
@@ -994,6 +1124,7 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
const installed = this._asBoolStrict(r?.installed);
|
const installed = this._asBoolStrict(r?.installed);
|
||||||
const installedVersion = this._safeText(r?.installed_version);
|
const installedVersion = this._safeText(r?.installed_version);
|
||||||
const updateAvailable = installed && !!latest && (!installedVersion || latest !== installedVersion);
|
const updateAvailable = installed && !!latest && (!installedVersion || latest !== installedVersion);
|
||||||
|
const favorite = this._asBoolStrict(r?.favorite) || this._isFavoriteRepo(r?.id);
|
||||||
|
|
||||||
const badges = [];
|
const badges = [];
|
||||||
// Source badges
|
// Source badges
|
||||||
@@ -1001,6 +1132,7 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
else if (r?.source === "hacs") badges.push("HACS");
|
else if (r?.source === "hacs") badges.push("HACS");
|
||||||
else if (r?.source === "custom") badges.push("Custom");
|
else if (r?.source === "custom") badges.push("Custom");
|
||||||
|
|
||||||
|
if (favorite) badges.push("Pinned");
|
||||||
if (installed) badges.push("Installed");
|
if (installed) badges.push("Installed");
|
||||||
if (updateAvailable) badges.push("Update");
|
if (updateAvailable) badges.push("Update");
|
||||||
|
|
||||||
@@ -1044,6 +1176,7 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
</select>
|
</select>
|
||||||
<select id="filter">
|
<select id="filter">
|
||||||
<option value="all" ${this._filter === "all" ? "selected" : ""}>All</option>
|
<option value="all" ${this._filter === "all" ? "selected" : ""}>All</option>
|
||||||
|
<option value="favorites" ${this._filter === "favorites" ? "selected" : ""}>Pinned</option>
|
||||||
<option value="installed" ${this._filter === "installed" ? "selected" : ""}>Installed</option>
|
<option value="installed" ${this._filter === "installed" ? "selected" : ""}>Installed</option>
|
||||||
<option value="not_installed" ${this._filter === "not_installed" ? "selected" : ""}>Not installed</option>
|
<option value="not_installed" ${this._filter === "not_installed" ? "selected" : ""}>Not installed</option>
|
||||||
<option value="updates" ${this._filter === "updates" ? "selected" : ""}>Updates available</option>
|
<option value="updates" ${this._filter === "updates" ? "selected" : ""}>Updates available</option>
|
||||||
@@ -1051,6 +1184,7 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
</select>
|
</select>
|
||||||
<select id="sort">
|
<select id="sort">
|
||||||
<option value="az" ${this._sort === "az" ? "selected" : ""}>A–Z</option>
|
<option value="az" ${this._sort === "az" ? "selected" : ""}>A–Z</option>
|
||||||
|
<option value="favorites_first" ${this._sort === "favorites_first" ? "selected" : ""}>Pinned first</option>
|
||||||
<option value="updates_first" ${this._sort === "updates_first" ? "selected" : ""}>Updates first</option>
|
<option value="updates_first" ${this._sort === "updates_first" ? "selected" : ""}>Updates first</option>
|
||||||
<option value="installed_first" ${this._sort === "installed_first" ? "selected" : ""}>Installed first</option>
|
<option value="installed_first" ${this._sort === "installed_first" ? "selected" : ""}>Installed first</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -1173,6 +1307,7 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
this._safeText(r?.latest_version) ? `Latest: ${this._safeText(r?.latest_version)}` : "Latest: -",
|
this._safeText(r?.latest_version) ? `Latest: ${this._safeText(r?.latest_version)}` : "Latest: -",
|
||||||
this._safeText(r?.provider) ? `Provider: ${this._safeText(r?.provider)}` : null,
|
this._safeText(r?.provider) ? `Provider: ${this._safeText(r?.provider)}` : null,
|
||||||
this._safeText(r?.category) ? `Category: ${this._safeText(r?.category)}` : null,
|
this._safeText(r?.category) ? `Category: ${this._safeText(r?.category)}` : null,
|
||||||
|
this._safeText(r?.install_target) ? `Target: ${this._safeText(r?.install_target)}` : null,
|
||||||
this._safeText(r?.meta_author) ? `Author: ${this._safeText(r?.meta_author)}` : null,
|
this._safeText(r?.meta_author) ? `Author: ${this._safeText(r?.meta_author)}` : null,
|
||||||
this._safeText(r?.meta_maintainer) ? `Maintainer: ${this._safeText(r?.meta_maintainer)}` : null,
|
this._safeText(r?.meta_maintainer) ? `Maintainer: ${this._safeText(r?.meta_maintainer)}` : null,
|
||||||
this._safeText(r?.meta_source) ? `Meta: ${this._safeText(r?.meta_source)}` : null,
|
this._safeText(r?.meta_source) ? `Meta: ${this._safeText(r?.meta_source)}` : null,
|
||||||
@@ -1215,7 +1350,10 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
const installed = this._asBoolStrict(r?.installed);
|
const installed = this._asBoolStrict(r?.installed);
|
||||||
const installedVersion = this._safeText(r?.installed_version);
|
const installedVersion = this._safeText(r?.installed_version);
|
||||||
const installedDomains = Array.isArray(r?.installed_domains) ? r.installed_domains : [];
|
const installedDomains = Array.isArray(r?.installed_domains) ? r.installed_domains : [];
|
||||||
|
const installedPaths = Array.isArray(r?.installed_paths) ? r.installed_paths : [];
|
||||||
const latestVersion = this._safeText(r?.latest_version);
|
const latestVersion = this._safeText(r?.latest_version);
|
||||||
|
const favorite = this._asBoolStrict(r?.favorite) || this._isFavoriteRepo(repoId);
|
||||||
|
const installType = this._safeText(r?.install_type) || "integration";
|
||||||
|
|
||||||
const busyInstall = this._installingRepoId === repoId;
|
const busyInstall = this._installingRepoId === repoId;
|
||||||
const busyUpdate = this._updatingRepoId === repoId;
|
const busyUpdate = this._updatingRepoId === repoId;
|
||||||
@@ -1250,10 +1388,36 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const releaseNotesBlock = this._releaseNotesLoading
|
||||||
|
? `<div class="card" style="margin-top:12px;">Loading release notes...</div>`
|
||||||
|
: this._releaseNotesText
|
||||||
|
? `
|
||||||
|
<div class="card" style="margin-top:12px;">
|
||||||
|
<div class="row" style="align-items:center;">
|
||||||
|
<div><strong>Release Notes</strong></div>
|
||||||
|
<div class="muted small">${this._esc(selectedRef || latestVersion || "-")}</div>
|
||||||
|
</div>
|
||||||
|
<div id="releaseNotesPretty" class="md" style="margin-top:12px;"></div>
|
||||||
|
<details>
|
||||||
|
<summary>Show raw release notes</summary>
|
||||||
|
<div style="margin-top:10px;">
|
||||||
|
<pre class="readme">${this._esc(this._releaseNotesText)}</pre>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: `
|
||||||
|
<div class="card" style="margin-top:12px;">
|
||||||
|
<div><strong>Release Notes</strong></div>
|
||||||
|
<div class="muted" style="margin-top:8px;">${this._esc(this._releaseNotesError || "Release notes not available for this version.")}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
const installBtn = `<button class="primary" id="btnInstall" ${installed || busy ? "disabled" : ""}>${busyInstall ? "Installing…" : installed ? "Installed" : "Install"}</button>`;
|
const installBtn = `<button class="primary" id="btnInstall" ${installed || busy ? "disabled" : ""}>${busyInstall ? "Installing…" : installed ? "Installed" : "Install"}</button>`;
|
||||||
const updateBtn = `<button class="primary" id="btnUpdate" ${!updateAvailable || busy ? "disabled" : ""}>${busyUpdate ? "Updating…" : updateAvailable ? "Update" : "Up to date"}</button>`;
|
const updateBtn = `<button class="primary" id="btnUpdate" ${!updateAvailable || busy ? "disabled" : ""}>${busyUpdate ? "Updating…" : updateAvailable ? "Update" : "Up to date"}</button>`;
|
||||||
const uninstallBtn = `<button class="primary" id="btnUninstall" ${!installed || busy ? "disabled" : ""}>${busyUninstall ? "Uninstalling…" : "Uninstall"}</button>`;
|
const uninstallBtn = `<button class="primary" id="btnUninstall" ${!installed || busy ? "disabled" : ""}>${busyUninstall ? "Uninstalling…" : "Uninstall"}</button>`;
|
||||||
const restoreBtn = `<button class="primary" id="btnRestore" ${!installed || busy ? "disabled" : ""}>Restore</button>`;
|
const restoreBtn = `<button class="primary" id="btnRestore" ${!installed || busy ? "disabled" : ""}>Restore</button>`;
|
||||||
|
const favoriteBtn = `<button id="btnFavorite">${favorite ? "Unpin" : "Pin"}</button>`;
|
||||||
|
|
||||||
const restartHint = this._restartRequired
|
const restartHint = this._restartRequired
|
||||||
? `
|
? `
|
||||||
@@ -1300,11 +1464,14 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
<div><strong>Installed version:</strong> ${this._esc(installedVersion || "-")}</div>
|
<div><strong>Installed version:</strong> ${this._esc(installedVersion || "-")}</div>
|
||||||
<div><strong>Latest version:</strong> ${this._esc(latestVersion || "-")}</div>
|
<div><strong>Latest version:</strong> ${this._esc(latestVersion || "-")}</div>
|
||||||
<div style="margin-top:6px;"><strong>Domains:</strong> ${installedDomains.length ? this._esc(installedDomains.join(", ")) : "-"}</div>
|
<div style="margin-top:6px;"><strong>Domains:</strong> ${installedDomains.length ? this._esc(installedDomains.join(", ")) : "-"}</div>
|
||||||
|
<div style="margin-top:6px;"><strong>Installed paths:</strong> ${installedPaths.length ? this._esc(installedPaths.join(", ")) : "-"}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${versionSelect}
|
${versionSelect}
|
||||||
|
${releaseNotesBlock}
|
||||||
|
|
||||||
<div class="row" style="margin-top:14px; gap:10px; flex-wrap:wrap;">
|
<div class="row" style="margin-top:14px; gap:10px; flex-wrap:wrap;">
|
||||||
|
${favoriteBtn}
|
||||||
${installBtn}
|
${installBtn}
|
||||||
${updateBtn}
|
${updateBtn}
|
||||||
${uninstallBtn}
|
${uninstallBtn}
|
||||||
@@ -1329,6 +1496,13 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
const btnRestart = root.getElementById("btnRestart");
|
const btnRestart = root.getElementById("btnRestart");
|
||||||
const btnReadmeToggle = root.getElementById("btnReadmeToggle");
|
const btnReadmeToggle = root.getElementById("btnReadmeToggle");
|
||||||
const selVersion = root.getElementById("selVersion");
|
const selVersion = root.getElementById("selVersion");
|
||||||
|
const btnFavorite = root.getElementById("btnFavorite");
|
||||||
|
|
||||||
|
if (btnFavorite) {
|
||||||
|
btnFavorite.addEventListener("click", () => {
|
||||||
|
if (this._detailRepoId) this._toggleFavorite(this._detailRepoId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (btnInstall) {
|
if (btnInstall) {
|
||||||
btnInstall.addEventListener("click", () => {
|
btnInstall.addEventListener("click", () => {
|
||||||
@@ -1342,6 +1516,7 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
if (!this._detailRepoId) return;
|
if (!this._detailRepoId) return;
|
||||||
const v = selVersion.value != null ? String(selVersion.value) : "";
|
const v = selVersion.value != null ? String(selVersion.value) : "";
|
||||||
this._selectedVersionByRepoId[this._detailRepoId] = v;
|
this._selectedVersionByRepoId[this._detailRepoId] = v;
|
||||||
|
this._loadReleaseNotes(this._detailRepoId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1380,7 +1555,22 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mount = root.getElementById("readmePretty");
|
const mount = root.getElementById("readmePretty");
|
||||||
if (!mount) return;
|
if (!mount) {
|
||||||
|
const releaseMount = root.getElementById("releaseNotesPretty");
|
||||||
|
if (releaseMount) {
|
||||||
|
if (this._releaseNotesText) {
|
||||||
|
if (this._releaseNotesHtml) {
|
||||||
|
releaseMount.innerHTML = this._releaseNotesHtml;
|
||||||
|
this._postprocessRenderedMarkdown(releaseMount);
|
||||||
|
} else {
|
||||||
|
releaseMount.innerHTML = `<div class="muted">Rendered HTML not available. Use "Show raw release notes".</div>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
releaseMount.innerHTML = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this._readmeText) {
|
if (this._readmeText) {
|
||||||
if (this._readmeHtml) {
|
if (this._readmeHtml) {
|
||||||
@@ -1392,6 +1582,20 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
} else {
|
} else {
|
||||||
mount.innerHTML = "";
|
mount.innerHTML = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const releaseMount = root.getElementById("releaseNotesPretty");
|
||||||
|
if (releaseMount) {
|
||||||
|
if (this._releaseNotesText) {
|
||||||
|
if (this._releaseNotesHtml) {
|
||||||
|
releaseMount.innerHTML = this._releaseNotesHtml;
|
||||||
|
this._postprocessRenderedMarkdown(releaseMount);
|
||||||
|
} else {
|
||||||
|
releaseMount.innerHTML = `<div class="muted">Rendered HTML not available. Use "Show raw release notes".</div>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
releaseMount.innerHTML = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_wireRestoreModal() {
|
_wireRestoreModal() {
|
||||||
@@ -1459,12 +1663,16 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
|
const restoreDetails = this._getInstallTypeDetails(this._restoreRepoId);
|
||||||
|
const actionHint = restoreDetails.restartRequired
|
||||||
|
? `Select a backup to restore. This will overwrite files under ${restoreDetails.targetPath} and requires a restart.`
|
||||||
|
: `Select a backup to restore. This will overwrite files under ${restoreDetails.targetPath}. No restart is required.`;
|
||||||
const msg = this._restoreLoading
|
const msg = this._restoreLoading
|
||||||
? "Loading backups…"
|
? "Loading backups…"
|
||||||
: this._restoreError
|
: this._restoreError
|
||||||
? this._safeText(this._restoreError)
|
? this._safeText(this._restoreError)
|
||||||
: opts.length
|
: opts.length
|
||||||
? "Select a backup to restore. This will overwrite files under /config/custom_components and requires a restart."
|
? actionHint
|
||||||
: "No backups found.";
|
: "No backups found.";
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -1554,4 +1762,4 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define("bahmcloud-store-panel", BahmcloudStorePanel);
|
customElements.define("bahmcloud-store-panel", BahmcloudStorePanel);
|
||||||
|
|||||||
@@ -678,4 +678,78 @@ async def fetch_repo_versions(
|
|||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
_LOGGER.debug("fetch_repo_versions failed for %s", repo_url, exc_info=True)
|
_LOGGER.debug("fetch_repo_versions failed for %s", repo_url, exc_info=True)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_release_notes_markdown(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
repo_url: str,
|
||||||
|
*,
|
||||||
|
ref: str | None,
|
||||||
|
provider: str | None = None,
|
||||||
|
github_token: str | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Fetch release notes for a specific release tag."""
|
||||||
|
|
||||||
|
repo_url = (repo_url or "").strip()
|
||||||
|
target_ref = (ref or "").strip()
|
||||||
|
if not repo_url or not target_ref:
|
||||||
|
return None
|
||||||
|
|
||||||
|
prov = (provider or "").strip().lower() if provider else ""
|
||||||
|
if not prov:
|
||||||
|
prov = detect_provider(repo_url)
|
||||||
|
|
||||||
|
owner, repo = _split_owner_repo(repo_url)
|
||||||
|
if not owner or not repo:
|
||||||
|
return None
|
||||||
|
|
||||||
|
session = async_get_clientsession(hass)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if prov == "github":
|
||||||
|
data, status = await _safe_json(
|
||||||
|
session,
|
||||||
|
f"https://api.github.com/repos/{owner}/{repo}/releases/tags/{quote_plus(target_ref)}",
|
||||||
|
headers=_github_headers(github_token),
|
||||||
|
)
|
||||||
|
if status == 200 and isinstance(data, dict):
|
||||||
|
body = data.get("body")
|
||||||
|
if isinstance(body, str) and body.strip():
|
||||||
|
return body
|
||||||
|
return None
|
||||||
|
|
||||||
|
if prov == "gitlab":
|
||||||
|
u = urlparse(repo_url.rstrip("/"))
|
||||||
|
base = f"{u.scheme}://{u.netloc}"
|
||||||
|
project = quote_plus(f"{owner}/{repo}")
|
||||||
|
data, status = await _safe_json(
|
||||||
|
session,
|
||||||
|
f"{base}/api/v4/projects/{project}/releases/{quote_plus(target_ref)}",
|
||||||
|
headers={"User-Agent": UA},
|
||||||
|
)
|
||||||
|
if status == 200 and isinstance(data, dict):
|
||||||
|
body = data.get("description")
|
||||||
|
if isinstance(body, str) and body.strip():
|
||||||
|
return body
|
||||||
|
return None
|
||||||
|
|
||||||
|
u = urlparse(repo_url.rstrip("/"))
|
||||||
|
base = f"{u.scheme}://{u.netloc}"
|
||||||
|
data, status = await _safe_json(
|
||||||
|
session,
|
||||||
|
f"{base}/api/v1/repos/{owner}/{repo}/releases/tags/{quote_plus(target_ref)}",
|
||||||
|
headers={"User-Agent": UA},
|
||||||
|
)
|
||||||
|
if status == 200 and isinstance(data, dict):
|
||||||
|
body = data.get("body")
|
||||||
|
if isinstance(body, str) and body.strip():
|
||||||
|
return body
|
||||||
|
note = data.get("note")
|
||||||
|
if isinstance(note, str) and note.strip():
|
||||||
|
return note
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.debug("fetch_release_notes_markdown failed for %s ref=%s", repo_url, target_ref, exc_info=True)
|
||||||
|
return None
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ class InstalledRepo:
|
|||||||
url: str
|
url: str
|
||||||
domains: list[str]
|
domains: list[str]
|
||||||
installed_at: int
|
installed_at: int
|
||||||
|
install_type: str = "integration"
|
||||||
|
installed_paths: list[str] | None = None
|
||||||
installed_version: str | None = None # BCS "installed ref" (tag/release/branch)
|
installed_version: str | None = None # BCS "installed ref" (tag/release/branch)
|
||||||
installed_manifest_version: str | None = None # informational only
|
installed_manifest_version: str | None = None # informational only
|
||||||
ref: str | None = None # kept for backward compatibility / diagnostics
|
ref: str | None = None # kept for backward compatibility / diagnostics
|
||||||
@@ -197,6 +199,11 @@ class BCSStorage:
|
|||||||
if not isinstance(domains, list):
|
if not isinstance(domains, list):
|
||||||
domains = []
|
domains = []
|
||||||
domains = [str(d) for d in domains if str(d).strip()]
|
domains = [str(d) for d in domains if str(d).strip()]
|
||||||
|
installed_paths = entry.get("installed_paths") or []
|
||||||
|
if not isinstance(installed_paths, list):
|
||||||
|
installed_paths = []
|
||||||
|
installed_paths = [str(p) for p in installed_paths if str(p).strip()]
|
||||||
|
install_type = str(entry.get("install_type") or "integration").strip() or "integration"
|
||||||
|
|
||||||
installed_version = entry.get("installed_version")
|
installed_version = entry.get("installed_version")
|
||||||
ref = entry.get("ref")
|
ref = entry.get("ref")
|
||||||
@@ -213,6 +220,8 @@ class BCSStorage:
|
|||||||
url=str(entry.get("url") or ""),
|
url=str(entry.get("url") or ""),
|
||||||
domains=domains,
|
domains=domains,
|
||||||
installed_at=int(entry.get("installed_at") or 0),
|
installed_at=int(entry.get("installed_at") or 0),
|
||||||
|
install_type=install_type,
|
||||||
|
installed_paths=installed_paths,
|
||||||
installed_version=str(installed_version) if installed_version else None,
|
installed_version=str(installed_version) if installed_version else None,
|
||||||
installed_manifest_version=str(installed_manifest_version) if installed_manifest_version else None,
|
installed_manifest_version=str(installed_manifest_version) if installed_manifest_version else None,
|
||||||
ref=str(ref) if ref else None,
|
ref=str(ref) if ref else None,
|
||||||
@@ -238,9 +247,11 @@ class BCSStorage:
|
|||||||
repo_id: str,
|
repo_id: str,
|
||||||
url: str,
|
url: str,
|
||||||
domains: list[str],
|
domains: list[str],
|
||||||
installed_version: str | None,
|
installed_version: str | None = None,
|
||||||
installed_manifest_version: str | None = None,
|
installed_manifest_version: str | None = None,
|
||||||
ref: str | None,
|
ref: str | None = None,
|
||||||
|
install_type: str = "integration",
|
||||||
|
installed_paths: list[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
data = await self._load()
|
data = await self._load()
|
||||||
installed = data.get("installed_repos", {})
|
installed = data.get("installed_repos", {})
|
||||||
@@ -252,6 +263,8 @@ class BCSStorage:
|
|||||||
"repo_id": str(repo_id),
|
"repo_id": str(repo_id),
|
||||||
"url": str(url),
|
"url": str(url),
|
||||||
"domains": [str(d) for d in (domains or []) if str(d).strip()],
|
"domains": [str(d) for d in (domains or []) if str(d).strip()],
|
||||||
|
"install_type": str(install_type or "integration"),
|
||||||
|
"installed_paths": [str(p) for p in (installed_paths or []) if str(p).strip()],
|
||||||
"installed_at": int(time.time()),
|
"installed_at": int(time.time()),
|
||||||
# IMPORTANT: this is what BCS uses as "installed version" (ref/tag/branch)
|
# IMPORTANT: this is what BCS uses as "installed version" (ref/tag/branch)
|
||||||
"installed_version": installed_version,
|
"installed_version": installed_version,
|
||||||
@@ -268,4 +281,4 @@ class BCSStorage:
|
|||||||
if isinstance(installed, dict) and repo_id in installed:
|
if isinstance(installed, dict) and repo_id in installed:
|
||||||
installed.pop(repo_id, None)
|
installed.pop(repo_id, None)
|
||||||
data["installed_repos"] = installed
|
data["installed_repos"] = installed
|
||||||
await self._save(data)
|
await self._save(data)
|
||||||
|
|||||||
@@ -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,6 +10,7 @@ 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 .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .core import SIGNAL_UPDATED, BCSCore
|
from .core import SIGNAL_UPDATED, BCSCore
|
||||||
@@ -127,8 +129,36 @@ class BCSRepoUpdateEntity(UpdateEntity):
|
|||||||
def _sync_entities(core: BCSCore, existing: dict[str, BCSRepoUpdateEntity], async_add_entities: AddEntitiesCallback) -> None:
|
def _sync_entities(core: BCSCore, existing: dict[str, BCSRepoUpdateEntity], async_add_entities: AddEntitiesCallback) -> None:
|
||||||
"""Ensure there is one update entity per installed repo AND keep names in sync."""
|
"""Ensure there is one update entity per installed repo AND keep names in sync."""
|
||||||
installed_map = getattr(core, "_installed_cache", {}) or {}
|
installed_map = getattr(core, "_installed_cache", {}) or {}
|
||||||
|
installed_repo_ids = {repo_id for repo_id, data in installed_map.items() if isinstance(data, dict)}
|
||||||
new_entities: list[BCSRepoUpdateEntity] = []
|
new_entities: list[BCSRepoUpdateEntity] = []
|
||||||
|
|
||||||
|
stale_repo_ids = [repo_id for repo_id in list(existing) if repo_id not in installed_repo_ids]
|
||||||
|
if stale_repo_ids:
|
||||||
|
registry = er.async_get(core.hass)
|
||||||
|
for repo_id in stale_repo_ids:
|
||||||
|
ent = existing.pop(repo_id, None)
|
||||||
|
if not ent:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
entity_id = getattr(ent, "entity_id", None)
|
||||||
|
if not entity_id:
|
||||||
|
for entry in registry.entities.values():
|
||||||
|
if getattr(entry, "platform", None) == DOMAIN and getattr(entry, "unique_id", None) == ent.unique_id:
|
||||||
|
entity_id = entry.entity_id
|
||||||
|
break
|
||||||
|
if entity_id:
|
||||||
|
registry.async_remove(entity_id)
|
||||||
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||||
|
_LOGGER.debug("BCS could not remove stale update entity for %s: %s", repo_id, exc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = ent.async_remove()
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
core.hass.async_create_task(result)
|
||||||
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||||
|
_LOGGER.debug("BCS could not detach stale update entity for %s: %s", repo_id, exc)
|
||||||
|
|
||||||
for repo_id, data in installed_map.items():
|
for repo_id, data in installed_map.items():
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
continue
|
continue
|
||||||
@@ -178,4 +208,4 @@ async def async_setup_entry(
|
|||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up BCS update entities from a config entry."""
|
"""Set up BCS update entities from a config entry."""
|
||||||
await async_setup_platform(hass, {}, async_add_entities, None)
|
await async_setup_platform(hass, {}, async_add_entities, None)
|
||||||
|
|||||||
@@ -275,6 +275,9 @@ class BCSSettingsView(HomeAssistantView):
|
|||||||
updates: dict[str, Any] = {}
|
updates: dict[str, Any] = {}
|
||||||
if "hacs_enabled" in data:
|
if "hacs_enabled" in data:
|
||||||
updates["hacs_enabled"] = bool(data.get("hacs_enabled"))
|
updates["hacs_enabled"] = bool(data.get("hacs_enabled"))
|
||||||
|
if "favorite_repo_ids" in data:
|
||||||
|
raw = data.get("favorite_repo_ids") or []
|
||||||
|
updates["favorite_repo_ids"] = raw if isinstance(raw, list) else []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
settings = await self.core.set_settings(updates)
|
settings = await self.core.set_settings(updates)
|
||||||
@@ -349,6 +352,41 @@ class BCSVersionsView(HomeAssistantView):
|
|||||||
return web.json_response({"ok": False, "message": str(e) or "List versions failed"}, status=500)
|
return web.json_response({"ok": False, "message": str(e) or "List versions failed"}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
class BCSReleaseNotesView(HomeAssistantView):
|
||||||
|
url = "/api/bcs/release_notes"
|
||||||
|
name = "api:bcs_release_notes"
|
||||||
|
requires_auth = True
|
||||||
|
|
||||||
|
def __init__(self, core: Any) -> None:
|
||||||
|
self.core: BCSCore = core
|
||||||
|
|
||||||
|
async def get(self, request: web.Request) -> web.Response:
|
||||||
|
repo_id = request.query.get("repo_id")
|
||||||
|
if not repo_id:
|
||||||
|
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
|
||||||
|
|
||||||
|
ref = request.query.get("ref")
|
||||||
|
ref = str(ref).strip() if ref is not None else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
notes = await self.core.fetch_release_notes_markdown(repo_id, ref=ref)
|
||||||
|
if not notes or not str(notes).strip():
|
||||||
|
return web.json_response(
|
||||||
|
{"ok": False, "message": "Release notes not found for this version."},
|
||||||
|
status=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
notes_str = str(notes)
|
||||||
|
html = _render_markdown_server_side(notes_str)
|
||||||
|
return web.json_response(
|
||||||
|
{"ok": True, "ref": ref, "release_notes": notes_str, "html": html},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
_LOGGER.exception("BCS release notes failed: %s", e)
|
||||||
|
return web.json_response({"ok": False, "message": str(e) or "Release notes failed"}, status=500)
|
||||||
|
|
||||||
|
|
||||||
class BCSInstallView(HomeAssistantView):
|
class BCSInstallView(HomeAssistantView):
|
||||||
url = "/api/bcs/install"
|
url = "/api/bcs/install"
|
||||||
name = "api:bcs_install"
|
name = "api:bcs_install"
|
||||||
@@ -502,6 +540,9 @@ class BCSRepoDetailView(HomeAssistantView):
|
|||||||
domains = inst.get("domains") or []
|
domains = inst.get("domains") or []
|
||||||
if not isinstance(domains, list):
|
if not isinstance(domains, list):
|
||||||
domains = []
|
domains = []
|
||||||
|
installed_paths = inst.get("installed_paths") or []
|
||||||
|
if not isinstance(installed_paths, list):
|
||||||
|
installed_paths = []
|
||||||
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
"ok": True,
|
"ok": True,
|
||||||
@@ -518,13 +559,18 @@ class BCSRepoDetailView(HomeAssistantView):
|
|||||||
"latest_version": repo.latest_version,
|
"latest_version": repo.latest_version,
|
||||||
"latest_version_source": repo.latest_version_source,
|
"latest_version_source": repo.latest_version_source,
|
||||||
"category": repo.meta_category,
|
"category": repo.meta_category,
|
||||||
|
"category_key": self.core._repo_install_type(repo),
|
||||||
|
"install_target": self.core._repo_install_target(repo),
|
||||||
"meta_author": repo.meta_author,
|
"meta_author": repo.meta_author,
|
||||||
"meta_maintainer": repo.meta_maintainer,
|
"meta_maintainer": repo.meta_maintainer,
|
||||||
"meta_source": repo.meta_source,
|
"meta_source": repo.meta_source,
|
||||||
"installed": installed,
|
"installed": installed,
|
||||||
|
"install_type": inst.get("install_type") or self.core._repo_install_type(repo),
|
||||||
"installed_version": inst.get("installed_version"),
|
"installed_version": inst.get("installed_version"),
|
||||||
"installed_manifest_version": inst.get("installed_manifest_version"),
|
"installed_manifest_version": inst.get("installed_manifest_version"),
|
||||||
"installed_domains": domains,
|
"installed_domains": domains,
|
||||||
|
"installed_paths": installed_paths,
|
||||||
|
"favorite": self.core.is_favorite_repo(repo.id),
|
||||||
}
|
}
|
||||||
}, status=200)
|
}, status=200)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
Reference in New Issue
Block a user