Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9448176ff4 | |||
| a029738ec8 | |||
| 754540d578 | |||
| 1103c5e326 | |||
| b7d4d0ded4 | |||
| a8ff892993 | |||
| 90223e3fc4 | |||
| 0f5504b67d | |||
| 5fff1b2692 | |||
| c8356c7603 | |||
| 0c49a50fc9 | |||
| fa48841645 | |||
| 1445fff739 | |||
| 5cf365f354 | |||
| f73ce4095c | |||
| 1484d53f8c | |||
| 0e99c9c59e | |||
| 644e61aab0 | |||
| 4c2a104af7 | |||
| 95dd8b9dc2 | |||
| 8b01c04a4c |
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>
|
||||||
47
.idea/changes.md
generated
Normal file
47
.idea/changes.md
generated
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
### 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>
|
||||||
123
.idea/start prompt.md
generated
Normal file
123
.idea/start prompt.md
generated
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
## Bahmcloud Store Start Prompt
|
||||||
|
|
||||||
|
You are working in the `Bahmcloud Store` repository.
|
||||||
|
|
||||||
|
Project identity:
|
||||||
|
- This is a Home Assistant custom integration named `bahmcloud_store`.
|
||||||
|
- The product goal is a provider-neutral store for Home Assistant custom integrations, similar in spirit to HACS, but not limited to GitHub.
|
||||||
|
- Current real provider implementation is strongest for GitHub, GitLab, and Gitea-compatible providers. Unknown providers currently fall through the Gitea-style code paths, so do not assume every arbitrary Git provider works without verification.
|
||||||
|
|
||||||
|
Working rules:
|
||||||
|
- All project-facing work must be done in English only, regardless of the language the user speaks in chat. This applies to code comments, documentation, changelog entries, release notes, commit messages, PR text, UI text, issue text, and any other project artifacts unless the user explicitly requests a specific exception for repository content.
|
||||||
|
- Never push, commit, tag, or create a release without explicit user approval.
|
||||||
|
- Always document every change in `.idea/changes.md` with the current date and a detailed description of what changed, why it changed, and any verification done.
|
||||||
|
- If a new release is created, update `CHANGELOG.md` with all relevant changes since the last released version.
|
||||||
|
- Never mention prompts, AI tools, KI tools, or files inside the `.idea` folder in commit messages, push descriptions, release notes, or changelog entries.
|
||||||
|
- Use plain version numbers for future git tags and release names, without a leading `v` prefix. Example: `0.7.4`, not `v0.7.4`.
|
||||||
|
- Never mention internal workflow rules or repository-internal prompt/process decisions in any public-facing project communication. This includes commit messages, push descriptions, git tags, release names, release notes, changelog entries, and similar outward-facing texts.
|
||||||
|
- Prefer changing the real active code paths, not legacy or duplicate files.
|
||||||
|
- When docs and code disagree, trust the current code first, then update docs to match verified behavior.
|
||||||
|
- Do not remove user changes or perform destructive git actions unless the user explicitly asks for them.
|
||||||
|
|
||||||
|
Repository facts to keep in mind:
|
||||||
|
- Main integration path: `custom_components/bahmcloud_store/`
|
||||||
|
- Entry point: `__init__.py`
|
||||||
|
- Main runtime/service layer: `core.py`
|
||||||
|
- HTTP API layer: `views.py`
|
||||||
|
- Provider detection and remote version/readme fetching: `providers.py`
|
||||||
|
- Repo metadata loading (`bcs.yaml`, `hacs.yaml`, `hacs.json`): `metadata.py`
|
||||||
|
- Persistent storage: `storage.py`
|
||||||
|
- Update entities: `update.py`
|
||||||
|
- Repairs restart flow: `repairs.py`
|
||||||
|
- Frontend panel actually loaded by Home Assistant: `panel/panel.js`
|
||||||
|
- Panel registration uses `/api/bahmcloud_store_static/panel.js?v=109`; if frontend assets change in a real release, bump the version query to break HA browser cache.
|
||||||
|
|
||||||
|
Current architecture summary:
|
||||||
|
- Setup is config-entry only. YAML configuration is intentionally unsupported and only logs a warning if present.
|
||||||
|
- Only one integration instance is allowed.
|
||||||
|
- The official store index URL is fixed in `const.py`:
|
||||||
|
`https://git.bahmcloud.de/bahmcloud/ha_store/raw/branch/main/store.yaml`
|
||||||
|
- The store index format currently used by the active code is a YAML mapping with:
|
||||||
|
- `refresh_seconds`
|
||||||
|
- `repos`
|
||||||
|
- each repo entry contains at least `url`, optionally `name` and `category`
|
||||||
|
- Refresh flow:
|
||||||
|
- integration initializes storage and caches
|
||||||
|
- after `homeassistant_started`, a delayed refresh runs
|
||||||
|
- periodic refresh also runs on an interval from the store index
|
||||||
|
- refresh merges official index repos, optional HACS repos, and user custom repos
|
||||||
|
- cached enrichment is applied first, installed repos are refreshed eagerly, and the rest are enriched in the background
|
||||||
|
- Optional HACS support exists behind the `hacs_enabled` setting and currently loads the official HACS integration list from `data-v2.hacs.xyz`. That path is GitHub-only metadata, not a general provider abstraction.
|
||||||
|
- Install/update flow:
|
||||||
|
- picks a ref from selected version, latest version, or default branch
|
||||||
|
- downloads a ZIP from the provider-specific archive endpoint
|
||||||
|
- extracts the repository
|
||||||
|
- finds `custom_components`
|
||||||
|
- installs every integration folder inside `custom_components/*` that contains `manifest.json`
|
||||||
|
- stores the installed ref and manifest version in HA storage
|
||||||
|
- creates backups before overwriting existing domains
|
||||||
|
- marks restart required through Repairs
|
||||||
|
- Backup/restore behavior:
|
||||||
|
- backups live under `/config/.bcs_backups/<domain>/<timestamp>/`
|
||||||
|
- restore updates stored installed-version info so the UI and update entities reflect the restored state
|
||||||
|
- retention is currently 5 backups per domain
|
||||||
|
- Installed-state reconciliation exists:
|
||||||
|
- if folders are deleted from `/config/custom_components`, stale installed entries are removed from storage
|
||||||
|
- BCS also tries to self-reconcile as installed when it was deployed externally
|
||||||
|
|
||||||
|
Public/API contract to preserve unless intentionally changed:
|
||||||
|
- `GET /api/bcs`
|
||||||
|
- `POST /api/bcs?action=refresh`
|
||||||
|
- `GET /api/bcs/settings`
|
||||||
|
- `POST /api/bcs/settings`
|
||||||
|
- `GET /api/bcs/readme?repo_id=...`
|
||||||
|
- `GET /api/bcs/versions?repo_id=...`
|
||||||
|
- `GET /api/bcs/repo?repo_id=...`
|
||||||
|
- `POST /api/bcs/install?repo_id=...&version=...`
|
||||||
|
- `POST /api/bcs/update?repo_id=...&version=...`
|
||||||
|
- `POST /api/bcs/uninstall?repo_id=...`
|
||||||
|
- `GET /api/bcs/backups?repo_id=...`
|
||||||
|
- `POST /api/bcs/restore?repo_id=...&backup_id=...`
|
||||||
|
- `POST /api/bcs/restart`
|
||||||
|
- `DELETE /api/bcs/custom_repo?id=...`
|
||||||
|
|
||||||
|
Storage model:
|
||||||
|
- Home Assistant storage key: `bcs_store`
|
||||||
|
- Stored sections:
|
||||||
|
- `custom_repos`
|
||||||
|
- `installed_repos`
|
||||||
|
- `settings`
|
||||||
|
- `hacs_cache`
|
||||||
|
- `repo_cache`
|
||||||
|
|
||||||
|
Frontend/UI facts:
|
||||||
|
- The active HA panel script is `custom_components/bahmcloud_store/panel/panel.js`.
|
||||||
|
- The richer UI is implemented there: source filtering, HACS toggle, repo detail loading, version selection, backups restore modal, restart action, and history handling.
|
||||||
|
- `panel/app.js`, `panel/index.html`, and `panel/styles.css` look like older standalone or legacy panel assets. Treat them as secondary unless you confirm they are still used for a real path.
|
||||||
|
|
||||||
|
Code-analysis findings that should influence future work:
|
||||||
|
- `store.py` represents an older store implementation with a different data model (`packages`, `source_path`, older API routes). It does not appear to be the active runtime path for the current integration flow.
|
||||||
|
- `custom_repo_view.py` duplicates logic that already exists in `views.py` and looks legacy/unreferenced.
|
||||||
|
- README files describe the project direction correctly at a high level, but some wording overstates provider generality. The actual code is provider-neutral in intent, but concretely implemented around GitHub, GitLab, and Gitea-style endpoints.
|
||||||
|
- The end-user and full READMEs contain some stale or inconsistent details compared with the current UI and code. Verify behavior in source before using README text as specification.
|
||||||
|
- There are visible encoding/mojibake issues in some documentation and older UI assets. Preserve valid UTF-8 when editing.
|
||||||
|
|
||||||
|
Project constraints to respect in future edits:
|
||||||
|
- Keep async I/O non-blocking in Home Assistant.
|
||||||
|
- Avoid startup-heavy network work before HA is fully started.
|
||||||
|
- Preserve repo-cache and HACS-cache behavior unless intentionally redesigning refresh logic.
|
||||||
|
- Preserve backup-before-overwrite safety for install/update/restore flows.
|
||||||
|
- Preserve update-entity behavior for installed repos.
|
||||||
|
- Keep the integration UI-admin-only and config-entry-based.
|
||||||
|
|
||||||
|
Recommended workflow for future tasks:
|
||||||
|
1. Read `README.md`, `README_DEVELOPER.md`, and relevant source files.
|
||||||
|
2. Verify whether the requested change belongs in active code or in legacy files.
|
||||||
|
3. Implement the change in the active runtime path.
|
||||||
|
4. Update documentation if behavior changed.
|
||||||
|
5. Append a detailed dated entry to `.idea/changes.md`.
|
||||||
|
6. If and only if a release is being prepared with user approval, fold release-worthy changes into `CHANGELOG.md`.
|
||||||
|
|
||||||
|
Current release baseline:
|
||||||
|
- `manifest.json` version is `0.7.4`
|
||||||
|
- Latest documented release in `CHANGELOG.md` is `0.7.4` dated `2026-03-23`
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
44
CHANGELOG.md
44
CHANGELOG.md
@@ -11,6 +11,50 @@ Sections:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 0.7.4 - 2026-03-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Pinned repositories support to let admins keep important integrations easy to find.
|
||||||
|
- Pin and unpin actions in the repository detail view.
|
||||||
|
- Store filtering and sorting options for pinned repositories.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Repository favorites are now persisted in settings and restored across reloads.
|
||||||
|
- Pinned repositories are highlighted in the store overview with a dedicated badge.
|
||||||
|
- Frontend asset version updated so the latest panel changes load immediately after deployment.
|
||||||
|
|
||||||
|
## 0.7.3 - 2026-03-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Release notes support in the repository detail view for provider releases.
|
||||||
|
- New backend endpoint to fetch release notes for the selected repository version.
|
||||||
|
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Home Assistant panel asset cache-buster bumped to ensure updated frontend assets load after deployment.
|
||||||
|
- Release-note lookups now clearly report when a selected version has no provider release body instead of implying notes exist for plain tags or branches.
|
||||||
|
|
||||||
|
## 0.7.2 – 2026-01-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- When Bahmcloud Store is installed via an external installer (files copied into /config/custom_components), it now reconciles itself as "installed" in BCS storage so update checks work immediately.
|
||||||
|
|
||||||
|
## 0.7.1 – 2026-01-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- GitHub version provider now reliably fetches the latest 20 releases/tags using authenticated API requests.
|
||||||
|
- Repositories that were previously fetched in a degraded state (only `latest` and `branch`) are now automatically refreshed on repository view.
|
||||||
|
- Cached version lists with incomplete data are no longer reused and are re-fetched from the provider.
|
||||||
|
|
||||||
|
## [0.7.0] - 2026-01-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Options dialog (gear icon) for the Bahmcloud Store integration.
|
||||||
|
- Optional GitHub token can now be set, changed or removed via the Home Assistant UI.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed missing options flow when clicking the integration settings button.
|
||||||
|
|
||||||
## [0.6.9] – 2026-01-19
|
## [0.6.9] – 2026-01-19
|
||||||
### Added
|
### Added
|
||||||
- New Home Assistant **GUI setup** (Config Flow) – no YAML configuration required.
|
- New Home Assistant **GUI setup** (Config Flow) – no YAML configuration required.
|
||||||
|
|||||||
@@ -6,54 +6,60 @@ from datetime import timedelta
|
|||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.components.panel_custom import async_register_panel
|
from homeassistant.components.panel_custom import async_register_panel
|
||||||
from homeassistant.helpers.event import async_track_time_interval, async_call_later
|
|
||||||
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||||
|
from homeassistant.helpers.event import async_call_later, async_track_time_interval
|
||||||
|
|
||||||
from .core import BCSCore, BCSConfig, BCSError
|
from .const import CONF_GITHUB_TOKEN, DEFAULT_STORE_URL, DOMAIN
|
||||||
from .config_flow import CONF_GITHUB_TOKEN
|
from .core import BCSError, BCSConfig, BCSCore
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
DOMAIN = "bahmcloud_store"
|
|
||||||
|
|
||||||
# Fixed store index URL (not configurable by users)
|
|
||||||
DEFAULT_STORE_URL = "https://git.bahmcloud.de/bahmcloud/ha_store/raw/branch/main/store.yaml"
|
|
||||||
|
|
||||||
PLATFORMS: list[str] = ["update"]
|
PLATFORMS: list[str] = ["update"]
|
||||||
|
|
||||||
DATA_ENTRY = f"{DOMAIN}_entry_runtime"
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
||||||
"""Set up Bahmcloud Store.
|
"""Set up Bahmcloud Store.
|
||||||
|
|
||||||
YAML configuration is intentionally not supported.
|
We intentionally do NOT support YAML configuration.
|
||||||
Setup must happen via the UI (Config Flow).
|
This method is kept so we can log a helpful message if someone tries.
|
||||||
"""
|
"""
|
||||||
if DOMAIN in (config or {}):
|
if DOMAIN in (config or {}):
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"BCS does not support configuration.yaml setup. "
|
"BCS YAML configuration is no longer supported. "
|
||||||
"Please remove 'bahmcloud_store:' from configuration.yaml and set up the integration via Settings -> Devices & Services."
|
"Please remove 'bahmcloud_store:' from configuration.yaml and set up the integration via the UI."
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up Bahmcloud Store from a config entry."""
|
"""Set up Bahmcloud Store from a config entry (UI setup)."""
|
||||||
token = (entry.options.get(CONF_GITHUB_TOKEN) or "").strip() or None
|
# Only one instance.
|
||||||
|
hass.data.setdefault(DOMAIN, {})
|
||||||
|
|
||||||
core = BCSCore(hass, BCSConfig(store_url=DEFAULT_STORE_URL, github_token=token))
|
github_token = (entry.options.get(CONF_GITHUB_TOKEN) or "").strip() or None
|
||||||
hass.data[DOMAIN] = core
|
|
||||||
|
core = BCSCore(
|
||||||
|
hass,
|
||||||
|
BCSConfig(
|
||||||
|
store_url=DEFAULT_STORE_URL,
|
||||||
|
github_token=github_token,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
hass.data[DOMAIN][entry.entry_id] = core
|
||||||
|
# Keep a convenient shortcut for platforms that previously used hass.data[DOMAIN] directly.
|
||||||
|
hass.data[DOMAIN]["_core"] = core
|
||||||
|
|
||||||
await core.async_initialize()
|
await core.async_initialize()
|
||||||
|
|
||||||
# Register HTTP views + static assets
|
# HTTP views + panel (registered once per entry; we only allow one entry).
|
||||||
from .views import (
|
from .views import (
|
||||||
StaticAssetsView,
|
StaticAssetsView,
|
||||||
BCSApiView,
|
BCSApiView,
|
||||||
BCSSettingsView,
|
BCSSettingsView,
|
||||||
BCSReadmeView,
|
BCSReadmeView,
|
||||||
BCSVersionsView,
|
BCSVersionsView,
|
||||||
|
BCSReleaseNotesView,
|
||||||
BCSRepoDetailView,
|
BCSRepoDetailView,
|
||||||
BCSCustomRepoView,
|
BCSCustomRepoView,
|
||||||
BCSInstallView,
|
BCSInstallView,
|
||||||
@@ -69,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))
|
||||||
@@ -78,29 +85,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
hass.http.register_view(BCSRestoreView(core))
|
hass.http.register_view(BCSRestoreView(core))
|
||||||
hass.http.register_view(BCSRestartView(core))
|
hass.http.register_view(BCSRestartView(core))
|
||||||
|
|
||||||
# Sidebar panel
|
|
||||||
await async_register_panel(
|
await async_register_panel(
|
||||||
hass,
|
hass,
|
||||||
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=111",
|
||||||
sidebar_title="Bahmcloud Store",
|
sidebar_title="Bahmcloud Store",
|
||||||
sidebar_icon="mdi:store",
|
sidebar_icon="mdi:store",
|
||||||
require_admin=True,
|
require_admin=True,
|
||||||
config={},
|
config={},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Forward platform setup (Update entities)
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
|
||||||
|
|
||||||
async def _do_startup_refresh(_now=None) -> None:
|
async def _do_startup_refresh(_now=None) -> None:
|
||||||
try:
|
try:
|
||||||
await core.full_refresh(source="startup")
|
await core.full_refresh(source="startup")
|
||||||
except BCSError as e:
|
except BCSError as e:
|
||||||
_LOGGER.error("Initial refresh failed: %s", e)
|
_LOGGER.error("Initial refresh failed: %s", e)
|
||||||
|
|
||||||
# Do not block HA startup: schedule refresh after HA started.
|
# Do not block startup; refresh after HA is up.
|
||||||
def _on_ha_started(_event) -> None:
|
def _on_ha_started(_event) -> None:
|
||||||
async_call_later(hass, 30, _do_startup_refresh)
|
async_call_later(hass, 30, _do_startup_refresh)
|
||||||
|
|
||||||
@@ -115,30 +118,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
_LOGGER.exception("Unexpected error during periodic refresh: %s", e)
|
_LOGGER.exception("Unexpected error during periodic refresh: %s", e)
|
||||||
|
|
||||||
interval_seconds = int(getattr(core, "refresh_seconds", 300) or 300)
|
interval_seconds = int(getattr(core, "refresh_seconds", 300) or 300)
|
||||||
unsub = async_track_time_interval(hass, periodic, timedelta(seconds=interval_seconds))
|
async_track_time_interval(hass, periodic, timedelta(seconds=interval_seconds))
|
||||||
|
|
||||||
# Store unload callbacks safely
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
runtimes = hass.data.setdefault(DATA_ENTRY, {})
|
|
||||||
runtimes[entry.entry_id] = {"unsub_interval": unsub}
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
try:
|
|
||||||
runtimes = hass.data.get(DATA_ENTRY, {}) or {}
|
|
||||||
rt = runtimes.pop(entry.entry_id, {}) if isinstance(runtimes, dict) else {}
|
|
||||||
unsub = rt.get("unsub_interval")
|
|
||||||
if callable(unsub):
|
|
||||||
unsub()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
if unload_ok:
|
if unload_ok:
|
||||||
hass.data.pop(DOMAIN, None)
|
try:
|
||||||
|
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return unload_ok
|
return unload_ok
|
||||||
|
|
||||||
|
|
||||||
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
|
||||||
await hass.config_entries.async_reload(entry.entry_id)
|
|
||||||
|
|||||||
@@ -1,87 +1,71 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.core import callback
|
||||||
|
|
||||||
DOMAIN = "bahmcloud_store"
|
from .const import CONF_GITHUB_TOKEN, DOMAIN
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
CONF_GITHUB_TOKEN = "github_token"
|
|
||||||
|
def _schema(default_token: str | None = None) -> vol.Schema:
|
||||||
|
default_token = (default_token or "").strip()
|
||||||
|
return vol.Schema({vol.Optional(CONF_GITHUB_TOKEN, default=default_token): str})
|
||||||
|
|
||||||
|
|
||||||
class BCSConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
class BahmcloudStoreConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
"""Config flow for Bahmcloud Store.
|
"""Config flow for Bahmcloud Store.
|
||||||
|
|
||||||
Design goals:
|
The store index URL is fixed and not user-configurable.
|
||||||
- GUI setup only (no YAML config)
|
The only optional setting is a GitHub token to increase API rate limits.
|
||||||
- store index URL is fixed (not configurable)
|
|
||||||
- optional GitHub token to raise API rate limits
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
|
|
||||||
async def async_step_user(self, user_input: dict[str, Any] | None = None):
|
async def async_step_user(self, user_input: dict | None = None):
|
||||||
"""Handle the initial step."""
|
# Allow only one instance.
|
||||||
# Single instance only
|
await self.async_set_unique_id(DOMAIN)
|
||||||
if self._async_current_entries():
|
self._abort_if_unique_id_configured()
|
||||||
return self.async_abort(reason="single_instance_allowed")
|
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is None:
|
||||||
token = (user_input.get(CONF_GITHUB_TOKEN) or "").strip() or None
|
return self.async_show_form(step_id="user", data_schema=_schema(None))
|
||||||
data: dict[str, Any] = {}
|
|
||||||
options: dict[str, Any] = {}
|
|
||||||
if token:
|
|
||||||
options[CONF_GITHUB_TOKEN] = token
|
|
||||||
|
|
||||||
return self.async_create_entry(title="Bahmcloud Store", data=data, options=options)
|
token = str(user_input.get(CONF_GITHUB_TOKEN, "")).strip() or None
|
||||||
|
|
||||||
schema = vol.Schema(
|
return self.async_create_entry(
|
||||||
{
|
title="Bahmcloud Store",
|
||||||
vol.Optional(CONF_GITHUB_TOKEN, default=""): str,
|
data={},
|
||||||
}
|
options={CONF_GITHUB_TOKEN: token} if token else {},
|
||||||
)
|
|
||||||
|
|
||||||
return self.async_show_form(
|
|
||||||
step_id="user",
|
|
||||||
data_schema=schema,
|
|
||||||
description_placeholders={
|
|
||||||
"rate_limit": "GitHub API is rate-limited without a token (recommended for large indexes / HACS).",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@callback
|
||||||
def async_get_options_flow(config_entry: config_entries.ConfigEntry):
|
def async_get_options_flow(config_entry: config_entries.ConfigEntry):
|
||||||
return BCSOptionsFlowHandler(config_entry)
|
return BahmcloudStoreOptionsFlowHandler(config_entry)
|
||||||
|
|
||||||
|
|
||||||
class BCSOptionsFlowHandler(config_entries.OptionsFlow):
|
class BahmcloudStoreOptionsFlowHandler(config_entries.OptionsFlow):
|
||||||
"""Handle options for Bahmcloud Store."""
|
"""Options flow to manage optional GitHub token."""
|
||||||
|
|
||||||
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
|
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
|
||||||
self.config_entry = config_entry
|
self._config_entry = config_entry
|
||||||
|
|
||||||
async def async_step_init(self, user_input: dict[str, Any] | None = None):
|
async def async_step_init(self, user_input: dict | None = None):
|
||||||
if user_input is not None:
|
if user_input is None:
|
||||||
token = (user_input.get(CONF_GITHUB_TOKEN) or "").strip() or None
|
current = self._config_entry.options.get(CONF_GITHUB_TOKEN) or ""
|
||||||
opts: dict[str, Any] = dict(self.config_entry.options)
|
return self.async_show_form(step_id="init", data_schema=_schema(str(current)))
|
||||||
if token:
|
|
||||||
opts[CONF_GITHUB_TOKEN] = token
|
|
||||||
else:
|
|
||||||
opts.pop(CONF_GITHUB_TOKEN, None)
|
|
||||||
return self.async_create_entry(title="", data=opts)
|
|
||||||
|
|
||||||
cur = (self.config_entry.options.get(CONF_GITHUB_TOKEN) or "").strip()
|
token = str(user_input.get(CONF_GITHUB_TOKEN, "")).strip() or None
|
||||||
schema = vol.Schema({vol.Optional(CONF_GITHUB_TOKEN, default=cur): str})
|
options = dict(self._config_entry.options)
|
||||||
|
|
||||||
return self.async_show_form(
|
# Allow clearing the token.
|
||||||
step_id="init",
|
if token:
|
||||||
data_schema=schema,
|
options[CONF_GITHUB_TOKEN] = token
|
||||||
description_placeholders={
|
else:
|
||||||
"rate_limit": "Optional. Adds GitHub authorization to avoid rate limits.",
|
options.pop(CONF_GITHUB_TOKEN, None)
|
||||||
},
|
|
||||||
)
|
return self.async_create_entry(title="", data=options)
|
||||||
|
|||||||
11
custom_components/bahmcloud_store/const.py
Normal file
11
custom_components/bahmcloud_store/const.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"""Constants for Bahmcloud Store."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
DOMAIN = "bahmcloud_store"
|
||||||
|
|
||||||
|
# Fixed store index URL (not user-configurable).
|
||||||
|
DEFAULT_STORE_URL = "https://git.bahmcloud.de/bahmcloud/ha_store/raw/branch/main/store.yaml"
|
||||||
|
|
||||||
|
# Config entry option keys
|
||||||
|
CONF_GITHUB_TOKEN = "github_token"
|
||||||
@@ -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
|
||||||
@@ -181,6 +188,108 @@ class BCSCore:
|
|||||||
|
|
||||||
return await self.hass.async_add_executor_job(_read)
|
return await self.hass.async_add_executor_job(_read)
|
||||||
|
|
||||||
|
async def _read_manifest_info_async(self) -> dict[str, str]:
|
||||||
|
"""Read manifest.json fields that help identify this integration."""
|
||||||
|
|
||||||
|
def _read() -> dict[str, str]:
|
||||||
|
try:
|
||||||
|
manifest_path = Path(__file__).resolve().parent / "manifest.json"
|
||||||
|
data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for k in ("version", "documentation", "name", "domain"):
|
||||||
|
v = data.get(k)
|
||||||
|
if v:
|
||||||
|
out[str(k)] = str(v)
|
||||||
|
return out
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return await self.hass.async_add_executor_job(_read)
|
||||||
|
|
||||||
|
def _normalize_repo_base(self, url: str) -> str:
|
||||||
|
"""Normalize repository URLs to a stable base for matching.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
https://git.example.tld/org/repo/raw/branch/main/store.yaml
|
||||||
|
becomes:
|
||||||
|
https://git.example.tld/org/repo
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
p = urlsplit(str(url or "").strip())
|
||||||
|
parts = [x for x in (p.path or "").split("/") if x]
|
||||||
|
base_path = "/" + "/".join(parts[:2]) if len(parts) >= 2 else (p.path or "")
|
||||||
|
return urlunsplit((p.scheme, p.netloc, base_path.rstrip("/"), "", "")).lower()
|
||||||
|
except Exception:
|
||||||
|
return str(url or "").strip().lower()
|
||||||
|
|
||||||
|
async def _ensure_self_marked_installed(self, repos: dict[str, RepoItem]) -> None:
|
||||||
|
"""Ensure BCS is treated as installed when deployed via external installer.
|
||||||
|
|
||||||
|
When users install BCS via an installer that places files into
|
||||||
|
/config/custom_components, our internal storage has no installed entry.
|
||||||
|
This breaks update detection for the BCS repo entry in the Store.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Already tracked as installed?
|
||||||
|
items = await self.storage.list_installed_repos()
|
||||||
|
for it in items:
|
||||||
|
if DOMAIN in [str(d) for d in (it.domains or [])]:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Files must exist on disk.
|
||||||
|
cc_root = Path(self.hass.config.path("custom_components"))
|
||||||
|
manifest_path = cc_root / DOMAIN / "manifest.json"
|
||||||
|
if not manifest_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
info = await self._read_manifest_info_async()
|
||||||
|
doc = (info.get("documentation") or "").strip()
|
||||||
|
name = (info.get("name") or "").strip()
|
||||||
|
ver = (info.get("version") or self.version or "unknown").strip()
|
||||||
|
|
||||||
|
doc_base = self._normalize_repo_base(doc) if doc else ""
|
||||||
|
|
||||||
|
# Identify the matching repo entry in our current repo list.
|
||||||
|
chosen: RepoItem | None = None
|
||||||
|
if doc_base:
|
||||||
|
for r in repos.values():
|
||||||
|
if self._normalize_repo_base(r.url) == doc_base:
|
||||||
|
chosen = r
|
||||||
|
break
|
||||||
|
|
||||||
|
if not chosen and name:
|
||||||
|
for r in repos.values():
|
||||||
|
if (r.name or "").strip().lower() == name.lower():
|
||||||
|
chosen = r
|
||||||
|
break
|
||||||
|
|
||||||
|
if not chosen:
|
||||||
|
for r in repos.values():
|
||||||
|
if "bahmcloud_store" in (r.url or "").lower():
|
||||||
|
chosen = r
|
||||||
|
break
|
||||||
|
|
||||||
|
if not chosen:
|
||||||
|
_LOGGER.debug("BCS self-install reconcile: could not match repo entry")
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.storage.set_installed_repo(
|
||||||
|
repo_id=chosen.id,
|
||||||
|
url=chosen.url,
|
||||||
|
domains=[DOMAIN],
|
||||||
|
installed_version=ver if ver != "unknown" else None,
|
||||||
|
installed_manifest_version=ver if ver != "unknown" else None,
|
||||||
|
ref=ver if ver != "unknown" else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER.info(
|
||||||
|
"BCS self-install reconcile: marked as installed (repo_id=%s version=%s)",
|
||||||
|
chosen.id,
|
||||||
|
ver,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.debug("BCS self-install reconcile failed", exc_info=True)
|
||||||
|
|
||||||
def add_listener(self, cb) -> None:
|
def add_listener(self, cb) -> None:
|
||||||
self._listeners.append(cb)
|
self._listeners.append(cb)
|
||||||
|
|
||||||
@@ -239,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
|
||||||
@@ -324,6 +461,12 @@ class BCSCore:
|
|||||||
# Apply persisted per-repo enrichment cache (instant UI after restart).
|
# Apply persisted per-repo enrichment cache (instant UI after restart).
|
||||||
self._apply_repo_cache(merged)
|
self._apply_repo_cache(merged)
|
||||||
|
|
||||||
|
# If BCS itself was installed via an external installer (i.e. files exist on disk
|
||||||
|
# but our storage has no installed entry yet), we still want update checks to work.
|
||||||
|
# Reconcile this once we have the current repo list.
|
||||||
|
await self._ensure_self_marked_installed(merged)
|
||||||
|
await self._refresh_installed_cache()
|
||||||
|
|
||||||
await self._enrich_installed_only(merged)
|
await self._enrich_installed_only(merged)
|
||||||
self.repos = merged
|
self.repos = merged
|
||||||
|
|
||||||
@@ -697,11 +840,7 @@ class BCSCore:
|
|||||||
|
|
||||||
async def process_one(r: RepoItem) -> None:
|
async def process_one(r: RepoItem) -> None:
|
||||||
async with sem:
|
async with sem:
|
||||||
info: RepoInfo = await fetch_repo_info(
|
info: RepoInfo = await fetch_repo_info(self.hass, r.url, github_token=self.config.github_token)
|
||||||
self.hass,
|
|
||||||
r.url,
|
|
||||||
github_token=self.config.github_token,
|
|
||||||
)
|
|
||||||
|
|
||||||
r.provider = info.provider or r.provider
|
r.provider = info.provider or r.provider
|
||||||
r.owner = info.owner or r.owner
|
r.owner = info.owner or r.owner
|
||||||
@@ -760,11 +899,7 @@ class BCSCore:
|
|||||||
|
|
||||||
async def _enrich_one_repo(self, r: RepoItem) -> None:
|
async def _enrich_one_repo(self, r: RepoItem) -> None:
|
||||||
"""Fetch provider info + metadata for a single repo item."""
|
"""Fetch provider info + metadata for a single repo item."""
|
||||||
info: RepoInfo = await fetch_repo_info(
|
info: RepoInfo = await fetch_repo_info(self.hass, r.url, github_token=self.config.github_token)
|
||||||
self.hass,
|
|
||||||
r.url,
|
|
||||||
github_token=self.config.github_token,
|
|
||||||
)
|
|
||||||
|
|
||||||
r.provider = info.provider or r.provider
|
r.provider = info.provider or r.provider
|
||||||
r.owner = info.owner or r.owner
|
r.owner = info.owner or r.owner
|
||||||
@@ -816,13 +951,21 @@ class BCSCore:
|
|||||||
_LOGGER.debug("BCS ensure_repo_details failed for %s", repo_id, exc_info=True)
|
_LOGGER.debug("BCS ensure_repo_details failed for %s", repo_id, exc_info=True)
|
||||||
return r
|
return r
|
||||||
|
|
||||||
async def list_repo_versions(self, repo_id: str, *, limit: int = 20) -> list[dict[str, str]]:
|
async def list_repo_versions(
|
||||||
|
self,
|
||||||
|
repo_id: str,
|
||||||
|
*,
|
||||||
|
limit: int = 20,
|
||||||
|
force_refresh: bool = False,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
repo = self.get_repo(repo_id)
|
repo = self.get_repo(repo_id)
|
||||||
if not repo:
|
if not repo:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Prefer cached version lists to avoid hammering provider APIs (notably GitHub unauthenticated
|
# Prefer cached version lists to avoid hammering provider APIs (notably GitHub unauthenticated
|
||||||
# rate limits). We refresh on-demand when the user opens the selector.
|
# rate limits). However, if the cached list is clearly a degraded fallback (e.g. only
|
||||||
|
# "Latest" + "Branch"), we treat it as stale and retry immediately when the user requests
|
||||||
|
# versions again.
|
||||||
cached = None
|
cached = None
|
||||||
cached_ts = 0
|
cached_ts = 0
|
||||||
async with self._repo_cache_lock:
|
async with self._repo_cache_lock:
|
||||||
@@ -831,8 +974,17 @@ class BCSCore:
|
|||||||
cached_ts = int(cached.get("versions_ts", 0) or 0)
|
cached_ts = int(cached.get("versions_ts", 0) or 0)
|
||||||
|
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
if isinstance(cached, dict) and cached.get("versions") and (now - cached_ts) < VERSIONS_CACHE_TTL_SECONDS:
|
cached_versions = list(cached.get("versions") or []) if isinstance(cached, dict) else []
|
||||||
return list(cached.get("versions") or [])
|
cache_fresh = (now - cached_ts) < VERSIONS_CACHE_TTL_SECONDS
|
||||||
|
|
||||||
|
# Cache hit if it's fresh and not degraded, unless the caller explicitly wants a refresh.
|
||||||
|
if (
|
||||||
|
not force_refresh
|
||||||
|
and cached_versions
|
||||||
|
and cache_fresh
|
||||||
|
and len(cached_versions) > 2
|
||||||
|
):
|
||||||
|
return cached_versions
|
||||||
|
|
||||||
try:
|
try:
|
||||||
versions = await fetch_repo_versions(
|
versions = await fetch_repo_versions(
|
||||||
@@ -840,8 +992,8 @@ class BCSCore:
|
|||||||
repo.url,
|
repo.url,
|
||||||
provider=repo.provider,
|
provider=repo.provider,
|
||||||
default_branch=repo.default_branch,
|
default_branch=repo.default_branch,
|
||||||
github_token=self.config.github_token,
|
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
github_token=self.config.github_token,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
versions = []
|
versions = []
|
||||||
@@ -1027,6 +1179,7 @@ class BCSCore:
|
|||||||
"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,
|
||||||
|
"favorite": self.is_favorite_repo(r.id),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
@@ -1043,6 +1196,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()
|
||||||
@@ -1673,4 +1843,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,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"domain": "bahmcloud_store",
|
"domain": "bahmcloud_store",
|
||||||
"name": "Bahmcloud Store",
|
"name": "Bahmcloud Store",
|
||||||
"version": "0.6.9",
|
"version": "0.7.4",
|
||||||
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
|
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
|
||||||
|
"config_flow": true,
|
||||||
"platforms": ["update"],
|
"platforms": ["update"],
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"codeowners": ["@bahmcloud"],
|
"codeowners": ["@bahmcloud"],
|
||||||
"iot_class": "local_polling"
|
"iot_class": "local_polling"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -442,6 +456,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 +470,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 +518,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 +976,37 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +1031,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 +1049,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 +1087,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 +1095,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 +1139,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 +1147,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>
|
||||||
@@ -1216,6 +1313,7 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
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 latestVersion = this._safeText(r?.latest_version);
|
const latestVersion = this._safeText(r?.latest_version);
|
||||||
|
const favorite = this._asBoolStrict(r?.favorite) || this._isFavoriteRepo(repoId);
|
||||||
|
|
||||||
const busyInstall = this._installingRepoId === repoId;
|
const busyInstall = this._installingRepoId === repoId;
|
||||||
const busyUpdate = this._updatingRepoId === repoId;
|
const busyUpdate = this._updatingRepoId === repoId;
|
||||||
@@ -1250,10 +1348,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
|
||||||
? `
|
? `
|
||||||
@@ -1303,8 +1427,10 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
</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 +1455,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 +1475,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 +1514,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 +1541,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() {
|
||||||
@@ -1554,4 +1717,4 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define("bahmcloud-store-panel", BahmcloudStorePanel);
|
customElements.define("bahmcloud-store-panel", BahmcloudStorePanel);
|
||||||
|
|||||||
@@ -16,17 +16,6 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
UA = "BahmcloudStore (Home Assistant)"
|
UA = "BahmcloudStore (Home Assistant)"
|
||||||
|
|
||||||
|
|
||||||
def _github_headers(github_token: str | None = None) -> dict[str, str]:
|
|
||||||
headers = {
|
|
||||||
"Accept": "application/vnd.github+json",
|
|
||||||
"User-Agent": UA,
|
|
||||||
}
|
|
||||||
token = (github_token or "").strip()
|
|
||||||
if token:
|
|
||||||
headers["Authorization"] = f"Bearer {token}"
|
|
||||||
return headers
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RepoInfo:
|
class RepoInfo:
|
||||||
owner: str | None = None
|
owner: str | None = None
|
||||||
@@ -331,6 +320,15 @@ async def _gitlab_latest_version(
|
|||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _github_headers(github_token: str | None = None) -> dict:
|
||||||
|
headers = {"Accept": "application/vnd.github+json", "User-Agent": UA}
|
||||||
|
token = (github_token or "").strip()
|
||||||
|
if token:
|
||||||
|
# PAT or fine-grained token
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
async def fetch_repo_info(hass: HomeAssistant, repo_url: str, *, github_token: str | None = None) -> RepoInfo:
|
async def fetch_repo_info(hass: HomeAssistant, repo_url: str, *, github_token: str | None = None) -> RepoInfo:
|
||||||
provider = detect_provider(repo_url)
|
provider = detect_provider(repo_url)
|
||||||
owner, repo = _split_owner_repo(repo_url)
|
owner, repo = _split_owner_repo(repo_url)
|
||||||
@@ -539,7 +537,7 @@ async def fetch_repo_versions(
|
|||||||
- source: release|tag|branch
|
- source: release|tag|branch
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Uses public endpoints (no tokens) for public repositories.
|
- Uses provider APIs; for GitHub we include the configured token (if any) to avoid unauthenticated rate limits.
|
||||||
- We prefer releases first (if available), then tags.
|
- We prefer releases first (if available), then tags.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -557,6 +555,8 @@ async def fetch_repo_versions(
|
|||||||
|
|
||||||
session = async_get_clientsession(hass)
|
session = async_get_clientsession(hass)
|
||||||
headers = {"User-Agent": UA}
|
headers = {"User-Agent": UA}
|
||||||
|
if prov == "github":
|
||||||
|
headers = _github_headers(github_token)
|
||||||
|
|
||||||
out: list[dict[str, str]] = []
|
out: list[dict[str, str]] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
@@ -575,11 +575,13 @@ async def fetch_repo_versions(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if prov == "github":
|
if prov == "github":
|
||||||
# Releases
|
# Releases (prefer these over tags)
|
||||||
|
# Use the configured GitHub token (if any) to avoid unauthenticated rate limits.
|
||||||
gh_headers = _github_headers(github_token)
|
gh_headers = _github_headers(github_token)
|
||||||
|
per_page = max(1, min(int(limit), 100))
|
||||||
data, _ = await _safe_json(
|
data, _ = await _safe_json(
|
||||||
session,
|
session,
|
||||||
f"https://api.github.com/repos/{owner}/{repo}/releases?per_page={int(limit)}",
|
f"https://api.github.com/repos/{owner}/{repo}/releases?per_page={per_page}",
|
||||||
headers=gh_headers,
|
headers=gh_headers,
|
||||||
)
|
)
|
||||||
if isinstance(data, list):
|
if isinstance(data, list):
|
||||||
@@ -597,7 +599,7 @@ async def fetch_repo_versions(
|
|||||||
# Tags
|
# Tags
|
||||||
data, _ = await _safe_json(
|
data, _ = await _safe_json(
|
||||||
session,
|
session,
|
||||||
f"https://api.github.com/repos/{owner}/{repo}/tags?per_page={int(limit)}",
|
f"https://api.github.com/repos/{owner}/{repo}/tags?per_page={per_page}",
|
||||||
headers=gh_headers,
|
headers=gh_headers,
|
||||||
)
|
)
|
||||||
if isinstance(data, list):
|
if isinstance(data, list):
|
||||||
@@ -676,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
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"config": {
|
"config": {
|
||||||
"abort": {
|
"abort": {
|
||||||
"single_instance_allowed": "Bahmcloud Store is already configured."
|
"single_instance_allowed": "Only one Bahmcloud Store instance can be configured."
|
||||||
},
|
},
|
||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"title": "Set up Bahmcloud Store",
|
"title": "Bahmcloud Store",
|
||||||
"description": "The store index is fixed to the official Bahmcloud Store.\n\nOptional: Add a GitHub token to increase API rate limits for GitHub/HACS repositories.",
|
"description": "Bahmcloud Store uses a fixed official store index. You can optionally add a GitHub token to increase API rate limits.",
|
||||||
"data": {
|
"data": {
|
||||||
"github_token": "GitHub token (optional)"
|
"github_token": "GitHub token (optional)"
|
||||||
}
|
}
|
||||||
@@ -16,8 +16,8 @@
|
|||||||
"options": {
|
"options": {
|
||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
"title": "Bahmcloud Store options",
|
"title": "Bahmcloud Store Options",
|
||||||
"description": "Optional: Configure a GitHub token to increase API rate limits.",
|
"description": "Optionally set or clear your GitHub token to reduce rate limiting.",
|
||||||
"data": {
|
"data": {
|
||||||
"github_token": "GitHub token (optional)"
|
"github_token": "GitHub token (optional)"
|
||||||
}
|
}
|
||||||
@@ -40,4 +40,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,17 +5,26 @@ from dataclasses import dataclass
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
|
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.core import HomeAssistant, callback
|
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 .core import DOMAIN, SIGNAL_UPDATED, BCSCore
|
from .const import DOMAIN
|
||||||
|
from .core import SIGNAL_UPDATED, BCSCore
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_core(hass: HomeAssistant) -> BCSCore | None:
|
||||||
|
data = hass.data.get(DOMAIN)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
c = data.get("_core")
|
||||||
|
return c if isinstance(c, BCSCore) else None
|
||||||
|
# Backwards compatibility (older versions used hass.data[DOMAIN] = core)
|
||||||
|
return data if isinstance(data, BCSCore) else None
|
||||||
|
|
||||||
|
|
||||||
def _pretty_repo_name(core: BCSCore, repo_id: str) -> str:
|
def _pretty_repo_name(core: BCSCore, repo_id: str) -> str:
|
||||||
"""Return a human-friendly name for a repo update entity."""
|
"""Return a human-friendly name for a repo update entity."""
|
||||||
try:
|
try:
|
||||||
@@ -140,9 +149,14 @@ def _sync_entities(core: BCSCore, existing: dict[str, BCSRepoUpdateEntity], asyn
|
|||||||
ent.async_write_ha_state()
|
ent.async_write_ha_state()
|
||||||
|
|
||||||
|
|
||||||
async def _async_setup(hass: HomeAssistant, async_add_entities: AddEntitiesCallback) -> None:
|
async def async_setup_platform(
|
||||||
"""Common update entity setup for both config entries and legacy YAML."""
|
hass: HomeAssistant,
|
||||||
core: BCSCore | None = hass.data.get(DOMAIN)
|
config,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
discovery_info=None,
|
||||||
|
):
|
||||||
|
"""Set up BCS update entities."""
|
||||||
|
core: BCSCore | None = _get_core(hass)
|
||||||
if not core:
|
if not core:
|
||||||
_LOGGER.debug("BCS core not available, skipping update platform setup")
|
_LOGGER.debug("BCS core not available, skipping update platform setup")
|
||||||
return
|
return
|
||||||
@@ -160,18 +174,8 @@ async def _async_setup(hass: HomeAssistant, async_add_entities: AddEntitiesCallb
|
|||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: ConfigEntry,
|
entry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up update entities from a config entry."""
|
"""Set up BCS update entities from a config entry."""
|
||||||
await _async_setup(hass, async_add_entities)
|
await async_setup_platform(hass, {}, async_add_entities, None)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_platform(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
config,
|
|
||||||
async_add_entities: AddEntitiesCallback,
|
|
||||||
discovery_info=None,
|
|
||||||
):
|
|
||||||
"""Legacy YAML setup (not supported, kept for safety)."""
|
|
||||||
await _async_setup(hass, async_add_entities)
|
|
||||||
@@ -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"
|
||||||
@@ -525,6 +563,7 @@ class BCSRepoDetailView(HomeAssistantView):
|
|||||||
"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,
|
||||||
|
"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