61 Commits
0.6.2 ... 0.7.1

Author SHA1 Message Date
5fff1b2692 add 0.7.1 2026-01-20 07:19:03 +00:00
c8356c7603 0.7.1 2026-01-20 07:17:45 +00:00
0c49a50fc9 0.7.1 2026-01-20 07:17:24 +00:00
fa48841645 0.7.1 2026-01-20 07:16:48 +00:00
1445fff739 add 0.7.0 2026-01-20 05:50:18 +00:00
5cf365f354 0.7 0 2026-01-20 05:48:27 +00:00
f73ce4095c 0.7.0 2026-01-20 05:47:49 +00:00
1484d53f8c 0.7.0 2026-01-20 05:47:00 +00:00
0e99c9c59e 0.7.0 2026-01-20 05:46:20 +00:00
644e61aab0 0.7.0 2026-01-20 05:45:41 +00:00
4c2a104af7 0.7.0 2026-01-20 05:45:02 +00:00
95dd8b9dc2 0.7.0 2026-01-20 05:44:32 +00:00
8b01c04a4c 0 7.0 2026-01-20 05:43:57 +00:00
981f56a693 add 0.6.9 2026-01-19 17:51:36 +00:00
f6bd04f354 0.6.9 2026-01-19 17:42:35 +00:00
8da8a26a90 0.6.9 2026-01-19 17:41:41 +00:00
42fe5afe52 0.6.9 2026-01-19 17:41:18 +00:00
437c020566 0.6.9 2026-01-19 17:40:27 +00:00
b863ed4d51 0.6.9 2026-01-19 17:39:37 +00:00
368642345d Add on 0.6.9 2026-01-19 17:39:09 +00:00
43bc31c8b4 0.6.9 2026-01-19 16:52:59 +00:00
357049aa7b add 0.6.8 2026-01-19 14:35:56 +00:00
d85ef5621c 0.6.8 2026-01-19 14:35:24 +00:00
faac180212 0.6.8 2026-01-19 14:35:05 +00:00
76d8a45f37 README_FULL.md aktualisiert 2026-01-19 09:00:21 +00:00
d68d322df6 README_DEVELOPER.md aktualisiert 2026-01-19 08:27:01 +00:00
113c951028 Full 2026-01-19 08:09:48 +00:00
2a4ab676ec Ne 2026-01-19 08:06:42 +00:00
c0a04f505e README.md aktualisiert 2026-01-19 07:53:33 +00:00
a63006bb07 Add full 2026-01-19 07:42:35 +00:00
f8e678215d Add Developer 2026-01-19 07:41:31 +00:00
f745f8ec1e Add readme 2026-01-19 07:39:35 +00:00
33502a6d99 add 0.6.7 2026-01-19 07:16:35 +00:00
1306ee9518 Change 0.6.7 2026-01-19 07:15:48 +00:00
e37546cab1 0.6 7 2026-01-19 07:15:30 +00:00
88c3233fd1 0.6.7 2026-01-19 07:14:58 +00:00
02f3047080 0.6 7 2026-01-19 07:14:22 +00:00
d4012589e6 add 0.6.6 2026-01-18 19:55:59 +00:00
8ac67fa60c 0.6.6 2026-01-18 19:53:34 +00:00
981490c152 0.6.6 2026-01-18 19:53:12 +00:00
99b2a0f0c5 0.6.6 2026-01-18 19:52:27 +00:00
7ead494765 0.6.6 2026-01-18 19:51:31 +00:00
342b6f6c57 0.6.6 2026-01-18 19:50:44 +00:00
66ca63b2be I 2026-01-18 19:35:19 +00:00
e8325f722f I 2026-01-18 19:32:58 +00:00
7c1a91937a add 0.6.5 2026-01-18 18:55:40 +00:00
7ac3289bb7 0.6.5 2026-01-18 18:53:52 +00:00
19bdbd1b9a 0.6.5 2026-01-18 18:53:30 +00:00
24363cd2ac 0.6.5 2026-01-18 18:52:55 +00:00
e19ca5bff1 0.6.5 2026-01-18 18:51:48 +00:00
05897d4370 0.6.5 2026-01-18 18:51:17 +00:00
7a3a28d87f add 0.6.4 2026-01-18 16:58:08 +00:00
240cded8a9 0.6.4 2026-01-18 16:56:23 +00:00
31e241f052 0.6.4 2026-01-18 16:56:04 +00:00
de579682a0 0.6.4 2026-01-18 16:55:36 +00:00
9acbd5046c Add 0.6.3 2026-01-18 15:54:21 +00:00
8d63c88e69 0.6.3 2026-01-18 15:53:52 +00:00
cffb0af60e 0.6.3 2026-01-18 15:53:03 +00:00
857b7a127a 0.6 3 2026-01-18 15:52:28 +00:00
66b24ece48 0.6.3 2026-01-18 15:52:01 +00:00
0cc3b466e0 0.6.3 2026-01-18 15:51:34 +00:00
16 changed files with 1723 additions and 78 deletions

View File

@@ -11,6 +11,128 @@ Sections:
---
## 0.7.1 2026-01-20
### Fixed
- GitHub version provider now reliably fetches the latest 20 releases/tags using authenticated API requests.
- Repositories that were previously fetched in a degraded state (only `latest` and `branch`) are now automatically refreshed on repository view.
- Cached version lists with incomplete data are no longer reused and are re-fetched from the provider.
## [0.7.0] - 2026-01-20
### Added
- Options dialog (gear icon) for the Bahmcloud Store integration.
- Optional GitHub token can now be set, changed or removed via the Home Assistant UI.
### Fixed
- Fixed missing options flow when clicking the integration settings button.
## [0.6.9] 2026-01-19
### Added
- New Home Assistant **GUI setup** (Config Flow) no YAML configuration required.
- Optional **GitHub Token** support to increase API limits (up to 5000 req/h).
Configurable via *Integration → Options*.
- Clear setup guidance and warning about GitHub rate limits.
- Automatic detection and warning if YAML setup is still present (ignored safely).
### Changed
- **store.yaml** URL is now fixed to the official Bahmcloud Store index:
https://git.bahmcloud.de/bahmcloud/ha_store/raw/branch/main/store.yaml
- Installation workflow fully aligned with standard HA integrations.
- Update platform migrated to `async_setup_entry`.
### Fixed
- Minor stability and persistence improvements in startup sequence.
- Prevented duplicate background initialization when HA reloads the integration.
### Notes
- To enable extended GitHub access, create a fine-grained personal access token
(read-only) at https://github.com/settings/tokens and add it via the integration options.
## 0.6.8 Performance & Cache Stabilization (2026-01-19)
### Fixed
- Fixed excessive GitHub API requests causing rate limiting
- Fixed missing persistence of repository versions and metadata
- Fixed background enrichment re-running after Home Assistant restart
### Changed
- Repository metadata (versions, README, descriptions) is now fetched once in the background
- Cached data is reused and only refreshed on explicit user interaction
- Improved separation between startup refresh and on-demand updates
### Improved
- Significantly reduced Home Assistant startup time
- Greatly reduced GitHub API usage
- More reliable version selection for GitHub and HACS repositories
### Notes
- Background tasks may still appear in DEBUG logs (expected behavior)
- No functional UI changes in this release
## [0.6.7] - 2026-01-19
### Fixed
- Repository metadata loaded on demand is now persisted and restored after Home Assistant restart.
- Background enrichment reliably updates latest versions and descriptions for HACS repositories.
- Cached repository data is correctly restored on startup for non-installed repositories.
### Changed
- Repository details are only considered fully enriched once latest version information is available.
- Improved reliability of background cache synchronization without impacting startup performance.
## [0.6.6] - 2026-01-18
### Added
- Source filter to limit repositories by origin: BCS Official, HACS, or Custom.
- Visual source badges for repositories (BCS Official, HACS, Custom).
- Restored HACS enable/disable toggle in the Store UI.
### Changed
- HACS repositories now display human-readable names and descriptions based on official HACS metadata.
- Improved Store usability on mobile devices by fixing back navigation from repository detail view.
### Fixed
- Fixed missing HACS toggle after UI updates.
- Fixed mobile browser back button exiting the Store instead of returning to the repository list.
## [0.6.5] - 2026-01-18
### Added
- Separate handling of HACS official repositories with an enable/disable toggle in the Store UI.
- HACS repositories are now loaded independently from the main store index.
### Changed
- Store index can remain minimal and curated; HACS repositories are no longer required in store.yaml.
- Improved Store performance and clarity by clearly separating repository sources.
### Fixed
- Browser cache issues resolved by proper panel cache-busting for UI updates.
### Internal
- No changes to install, update, backup, or restore logic.
- Fully backward compatible with existing installations and configurations.
## [0.6.4] - 2026-01-18
### Fixed
- Fixed long Home Assistant startup times caused by background repository enrichment running too early.
### Changed
- Background repository enrichment is now started only after Home Assistant has fully started.
- Repository cache updates now run fully asynchronous without blocking Home Assistant startup.
### Internal
- Improved alignment with Home Assistant startup lifecycle.
- No functional changes to store behavior or UI.
## [0.6.3] - 2026-01-18
### Changed
- Improved Store performance for large indexes by avoiding full metadata enrichment during list refresh.
- Repository details are loaded on demand, reducing initial load time and network requests.
- Index refresh is skipped when the index content has not changed.
## [0.6.2] - 2026-01-18
### Added

View File

@@ -1,3 +1,50 @@
# bahmcloud_store
# Bahmcloud Store (BCS) for Home Assistant
Bahmcloud Store for installing costum_components to Homeassistant
Bahmcloud Store (BCS) is a provider-neutral store for Home Assistant custom integrations.
It allows you to browse, install, update, downgrade, uninstall and restore integrations
directly from the Home Assistant UI.
This README is intended for **end users**.
---
## Installation
### Option A: Home Assistant OS / Supervised (Add-on recommended)
1. Open **Settings → Add-ons → Add-on Store**
2. Add the Bahmcloud Add-on repository
`https://git.bahmcloud.de/bahmcloud/addons`
3. Install **Bahmcloud Store Installer**
4. Start the add-on
5. Restart Home Assistant
Installed to:
/config/custom_components/bahmcloud_store
---
### Option B: Manual Installation (Container / Core)
1. Download the latest release
2. Copy `custom_components/bahmcloud_store` to:
/config/custom_components/bahmcloud_store
3. Restart Home Assistant
---
## Repository Sources
- **BCS Official**
- **HACS**
- **Custom**
---
## Documentation
Developer documentation:
https://git.bahmcloud.de/bahmcloud/bahmcloud_store/src/branch/main/README_DEVELOPER.md
Full technical documentation:
https://git.bahmcloud.de/bahmcloud/bahmcloud_store/src/branch/main/README_FULL.md

115
README_DEVELOPER.md Normal file
View File

@@ -0,0 +1,115 @@
# Bahmcloud Store Developer Documentation
For contributors and maintainers.
## Architecture
Repositories:
1) Installer Add-on (HAOS/Supervised) ```https://git.bahmcloud.de/bahmcloud/addons```
2) Core Integration ```https://git.bahmcloud.de/bahmcloud/bahmcloud_store```
3) Store Index (`store.yaml`) ```https://git.bahmcloud.de/bahmcloud/ha_store```
### Integration Layout
custom_components/bahmcloud_store/
- __init__.py: setup, panel registration, schedule background after HA started
- core.py: index merge, enrichment, install/update/uninstall, backups, restore, caching
- providers.py: GitHub/GitLab/Gitea repo info + latest version helpers
- metadata.py: read bcs.yaml / hacs.json / hacs.yaml
- storage.py: persistent storage (installed, custom, repo cache, hacs cache)
- views.py: HTTP API endpoints
- update.py: UpdateEntity implementation
- repairs.py: (optional) Repairs flow for restart
- panel/: UI (panel.js, styles.css, etc.)
- manifest.json
## Runtime Model
- RepoItem (merged)
- Installed repos (storage)
- Repo cache (persisted enrichment)
- HACS meta cache (mapping owner/repo → name/description)
## Background
- Heavy work only after `homeassistant_started`
- Refresh: if index unchanged → installed-only refresh + schedule enrichment
- Opening a repo triggers `ensure_repo_details()` and persists to cache
## Providers
- GitHub: API/releases/tags/atom + raw readme
- GitLab: API releases/tags + raw readme
- Gitea: API releases/tags + raw readme
- Custom: API or HTTPS Request to your Git Provider
## Metadata
- Prefer `bcs.yaml`, fallback `hacs.json` / `hacs.yaml`
- Populate name/description/category/author/maintainer
## HTTP API (excerpt)
Base: /api/bcs
- GET /api/bcs
- POST /api/bcs?action=refresh
- GET /api/bcs/readme?repo_id=...
- GET /api/bcs/versions?repo_id=...
- POST /api/bcs/install?repo_id=...&version=...
- POST /api/bcs/update?repo_id=...&version=...
- POST /api/bcs/uninstall?repo_id=...
- GET /api/bcs/backups?repo_id=...
- POST /api/bcs/restore?repo_id=...&backup_id=...
- (optional) POST/DELETE custom_repo
## Update Entities
- Unique id bcs:<repo_id>
- Compare installed ref vs latest ref
- Dispatcher signal on refresh/install/update
## Storage
- JSON in HA `.storage`
- async read/write helpers
- repo cache applied on startup
## Contributing to **BCS Official**
1) Add pull request to `https://git.bahmcloud.de/bahmcloud/ha_store` (with your integration added to) `store.yaml`)
2) Add entry:
```yaml
- name: Your Integration Name
url: https://your-git-hoster.com/your-org/your-repo
category: Category (actually only "Integrations" are supported)
```
3) (Recommended) Add `bcs.yaml` to your repo:
```yaml
name: Your Integration Name
description: One-liner for the store (optional, store information are also catched from git repository)
category: Integrations (actually only supported)
author: Your Name
maintainer: Your Handle
```
4) Open PR; validation checks: reachable, has `custom_components/<domain>/manifest.json`, sensible metadata
5) Merge → appears in **BCS Official** after refresh
## Coding Guidelines
- Async I/O, no blocking event loop
- Respect provider rate limits
- Clean logging around refresh/install/update/restore
- Keep UI responsive; throttle updates
---
## Planed Features
- Add Downloads and install for category "Dashboard"
- Add Downloads and install for category "Template"
- Add Downloads and install for category "Theme"
- Add Downloads and install for category "Blueprint"
-

217
README_FULL.md Normal file
View File

@@ -0,0 +1,217 @@
# Bahmcloud Store Full User Guide
This guide explains **all features** of Bahmcloud Store (BCS) for Home Assistant.
It is written for users and admins who want a complete, practical reference.
> BCS lets you install & manage custom integrations from **GitHub/GitLab/Gitea** and your own sources, with backups, restore, and version pinning.
---
## Contents
- Concepts
- Sources (BCS / HACS / Custom)
- UI Overview
- Finding Integrations
- Installing
- Selecting Versions / Downgrading
- Updating
- Uninstalling
- Backups & Restore
- Custom Repositories
- HACS Repositories
- Update Entities in Home Assistant
- Background Caching & Performance
- Restart Required
- Troubleshooting
- FAQ
---
## Concepts
- **Sources**:
- **BCS Official** → entries from index (`store.yaml`) made and added from Bahmcloud
- **HACS** → official HACS integrations list (toggleable)
- **Custom** → manual entries you add locally
- **Install location**: `/config/custom_components/<domain>`
- **Backup**: BCS keeps preupdate copies in `/config/.bcs_backups/<domain>/<timestamp>/`
---
## Sources (BCS / HACS / Custom)
Each repository card shows a **source badge**:
- **BCS Official** from Bahmcloud index
- **HACS** from HACS official list (enable with the toggle)
- **Custom** added by you
You can **filter by source** with the **Source** dropdown (All / BCS Official / HACS / Custom).
---
## UI Overview
Top bar:
- **Search** (name/description)
- **Source** filter (All/BCS Official/HACS/Custom)
- **Category** filter (actually only Integrations are supported)
- **State-Filter** filter (All/Installed/Not installed/Update available/Custom repos)
- **Sort** (name (A-Z)/Updates first/Installed first)
- **HACS official** toggle (on/off)
Repository card:
- Name, description, badges (source, installed/update), category
- Buttons: **Install / Update / Uninstall**
- **Readme** expandable
- **Open** to see details (available versions, metadata)
---
## Finding Integrations
1. Use **Search** to filter by keywords.
2. Combine with **Category** and **Source**.
3. Sort to surface desired results.
Descriptions and latest versions are filled progressively by a background process; opening a repo loads details on demand.
---
## Installing
1. Open a repository.
2. Optionally select **Install version** (default: **Latest**).
3. Click **Install** and wait for confirmation.
4. Follow the **Restart required** prompt. (or use restart toggle in HomeAssistant Settings)
**What happens internally**
- BCS downloads the repository ZIP for the selected version (release/tag/branch).
- It extracts all integrations found under `custom_components/<domain>` and deploys them.
- It saves the **installed version (ref)** to track updates reliably, even if the repos own `manifest.json` is wrong/outdated.
---
## Selecting Versions / Downgrading
- Use the **Install version** dropdown in the detail view.
- Choose **Latest** or a previous **release/tag**.
- Installing a chosen ref **pins** the integration to that ref (no surprise updates).
- You can upgrade again later by selecting **Latest** and clicking **Update**.
---
## Updating
- The **Update** button appears when `latest_version` differs from your **installed version (ref)**.
- Updates are also available via **Home Assistant → Settings → Updates** (native Update entity).
- Clicking **Update** runs the same safe pipeline as **Install** (with backup).
**Tip:** Opening a repository detail view forces an immediate check for the latest version for that repo.
---
## Uninstalling
- Click **Uninstall** on the repository.
- BCS removes the integration folders under `custom_components/<domain>`.
- The installed state is cleared in the Store.
- Restart Home Assistant if prompted.
---
## Backups & Restore
Before an update/install over existing files, BCS creates a backup:
```
/config/.bcs_backups/<domain>/<timestamp>/
```
**Restore**:
1. Open the repository in store.
2. Select **Restore…**.
3. Pick one of the **last backups** (up to retention limit). (Version details are showed in ())
4. Confirm BCS restores files and reconciles installed version to the restored ref.
5. Restart Home Assistant if prompted.
If the old backup lacks metadata, BCS besteffort derives the installed version from the backups `manifest.json`, or marks the ref as `restored:<timestamp>` so updates remain possible.
---
## Custom Repositories
You can add any public repository (GitHub/GitLab/Gitea/Any other Git Hoster). BCS will attempt to detect:
- provider & default branch
- latest version (release/tag/atom)
- repo metadata (prefer `bcs.yaml`, fallback `hacs.json/hacs.yaml`)
- readme (common filenames)
**Add an custom repository**(typical flows):
- From the Store UI
1. If you start up Bahmcloud Store, you are on tab "Store"
2. Go to tab "Manage"
3. Add Repository URL (Example: ``https://git.bahmcloud.de/bahmcloud/bahmcloud_store``) and (optional) Name you want to show up (will be grabbed from GIT Repository)
4. Repository show up in Custom Repositories-list and in "Store" Tab, if available
**Remove an custom repository**(typical flows):
- From the Store UI
1. If you start up Bahmcloud Store, you are on tab "Store"
2. Go to tab "Manage"
3. Your repository you want to remove (if its custom) show up in the list
4. Press "Remove" Button to delete from Store (Attention: If you Remove installed Repository, you won´t be able to Uninstall from Store, you have to remove by your own (Delete Folder from ```config/custom_components/*```))
Custom repos get the **Custom** badge and can be filtered via **Source**.
---
## HACS Repositories
Enable the **HACS official** toggle to include official HACS integrations.
- BCS downloads the HACS integration list and maps **humanreadable names/descriptions** from HACS metadata.
- HACS entries are **not** part of your BCS-Official Repositories (avoid duplicate entries).
With many HACS repos, metadata loads in the background; names/descriptions appear progressively and are cached.
---
## Update Entities in Home Assistant
BCS exposes update entities for installed repos:
- Found under **Settings → Updates**
- Clicking **Install** triggers BCS update pipeline
- Shows **installed** and **latest** versions (BCS ref logic)
---
## Background Caching & Performance
- **Fast initial list**: index + local cache only
- **Background enrichment**: provider info, latest version, metadata, description, readme (best effort)
- **Ondemand**: opening a repo triggers immediate enrichment; data is **persisted** to cache
- **Persistent cache**: survives HA restarts; speeds up subsequent runs
- **Refresh**: immediately rechecks installed repos and key metadata
---
## Restart Required
After install, update, or restore, BCS raises a **Restart required** item in Home Assistant (Repairs). You can restart directly from there.
---
## Troubleshooting
- **New release but no update**: Open the repo detail once; ensure its a **release/tag** (commits alone dont change the ref).
- **Descriptions/Latest missing**: Wait for background enrichment or open the repo detail (forces enrichment). Cached afterwards.
(You also can Install new version by selecting in Version tab)
- **Slow startup**: BCS schedules heavy work after HA started. Keep indexes reasonable.
---
## FAQ
- **Backups path?** `/config/.bcs_backups/<domain>/<timestamp>/`
- **Install path?** `/config/custom_components/<domain>`
- **Downgrade?** Yes, pick an older version and install.
- **Restart needed?** Yes, after install/update/restore.

View File

@@ -4,7 +4,7 @@ description: >
Supports GitHub, GitLab, Gitea and Bahmcloud repositories with
a central index, UI panel and API, similar to HACS but independent.
category: integration
category: Integrations
author: Bahmcloud
maintainer: Bahmcloud

View File

@@ -3,39 +3,63 @@ from __future__ import annotations
import logging
from datetime import timedelta
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.components.panel_custom import async_register_panel
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.helpers.event import async_call_later, async_track_time_interval
from .core import BCSCore, BCSConfig, BCSError
from .const import CONF_GITHUB_TOKEN, DEFAULT_STORE_URL, DOMAIN
from .core import BCSError, BCSConfig, BCSCore
_LOGGER = logging.getLogger(__name__)
DOMAIN = "bahmcloud_store"
DEFAULT_STORE_URL = "https://git.bahmcloud.de/bahmcloud/ha_store/raw/branch/main/store.yaml"
CONF_STORE_URL = "store_url"
PLATFORMS: list[str] = ["update"]
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
cfg = config.get(DOMAIN, {}) or {}
store_url = cfg.get(CONF_STORE_URL, DEFAULT_STORE_URL)
"""Set up Bahmcloud Store.
core = BCSCore(hass, BCSConfig(store_url=store_url))
hass.data[DOMAIN] = core
We intentionally do NOT support YAML configuration.
This method is kept so we can log a helpful message if someone tries.
"""
if DOMAIN in (config or {}):
_LOGGER.warning(
"BCS YAML configuration is no longer supported. "
"Please remove 'bahmcloud_store:' from configuration.yaml and set up the integration via the UI."
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Bahmcloud Store from a config entry (UI setup)."""
# Only one instance.
hass.data.setdefault(DOMAIN, {})
github_token = (entry.options.get(CONF_GITHUB_TOKEN) or "").strip() or None
core = BCSCore(
hass,
BCSConfig(
store_url=DEFAULT_STORE_URL,
github_token=github_token,
),
)
hass.data[DOMAIN][entry.entry_id] = core
# Keep a convenient shortcut for platforms that previously used hass.data[DOMAIN] directly.
hass.data[DOMAIN]["_core"] = core
await core.async_initialize()
# Provide native Update entities in Settings -> System -> Updates.
# This integration is YAML-based (async_setup), therefore we load the platform manually.
await async_load_platform(hass, "update", DOMAIN, {}, config)
# HTTP views + panel (registered once per entry; we only allow one entry).
from .views import (
StaticAssetsView,
BCSApiView,
BCSSettingsView,
BCSReadmeView,
BCSVersionsView,
BCSRepoDetailView,
BCSCustomRepoView,
BCSInstallView,
BCSUpdateView,
@@ -47,8 +71,10 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
hass.http.register_view(StaticAssetsView())
hass.http.register_view(BCSApiView(core))
hass.http.register_view(BCSSettingsView(core))
hass.http.register_view(BCSReadmeView(core))
hass.http.register_view(BCSVersionsView(core))
hass.http.register_view(BCSRepoDetailView(core))
hass.http.register_view(BCSCustomRepoView(core))
hass.http.register_view(BCSInstallView(core))
hass.http.register_view(BCSUpdateView(core))
@@ -62,17 +88,24 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
frontend_url_path="bahmcloud-store",
webcomponent_name="bahmcloud-store-panel",
# IMPORTANT: bump v to avoid caching old JS
module_url="/api/bahmcloud_store_static/panel.js?v=105",
module_url="/api/bahmcloud_store_static/panel.js?v=109",
sidebar_title="Bahmcloud Store",
sidebar_icon="mdi:store",
require_admin=True,
config={},
)
try:
await core.full_refresh(source="startup")
except BCSError as e:
_LOGGER.error("Initial refresh failed: %s", e)
async def _do_startup_refresh(_now=None) -> None:
try:
await core.full_refresh(source="startup")
except BCSError as e:
_LOGGER.error("Initial refresh failed: %s", e)
# Do not block startup; refresh after HA is up.
def _on_ha_started(_event) -> None:
async_call_later(hass, 30, _do_startup_refresh)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _on_ha_started)
async def periodic(_now) -> None:
try:
@@ -85,4 +118,16 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
interval_seconds = int(getattr(core, "refresh_seconds", 300) or 300)
async_track_time_interval(hass, periodic, timedelta(seconds=interval_seconds))
return True
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
try:
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
except Exception:
pass
return unload_ok

View File

@@ -0,0 +1,71 @@
from __future__ import annotations
import logging
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from .const import CONF_GITHUB_TOKEN, DOMAIN
_LOGGER = logging.getLogger(__name__)
def _schema(default_token: str | None = None) -> vol.Schema:
default_token = (default_token or "").strip()
return vol.Schema({vol.Optional(CONF_GITHUB_TOKEN, default=default_token): str})
class BahmcloudStoreConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Config flow for Bahmcloud Store.
The store index URL is fixed and not user-configurable.
The only optional setting is a GitHub token to increase API rate limits.
"""
VERSION = 1
async def async_step_user(self, user_input: dict | None = None):
# Allow only one instance.
await self.async_set_unique_id(DOMAIN)
self._abort_if_unique_id_configured()
if user_input is None:
return self.async_show_form(step_id="user", data_schema=_schema(None))
token = str(user_input.get(CONF_GITHUB_TOKEN, "")).strip() or None
return self.async_create_entry(
title="Bahmcloud Store",
data={},
options={CONF_GITHUB_TOKEN: token} if token else {},
)
@staticmethod
@callback
def async_get_options_flow(config_entry: config_entries.ConfigEntry):
return BahmcloudStoreOptionsFlowHandler(config_entry)
class BahmcloudStoreOptionsFlowHandler(config_entries.OptionsFlow):
"""Options flow to manage optional GitHub token."""
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
self._config_entry = config_entry
async def async_step_init(self, user_input: dict | None = None):
if user_input is None:
current = self._config_entry.options.get(CONF_GITHUB_TOKEN) or ""
return self.async_show_form(step_id="init", data_schema=_schema(str(current)))
token = str(user_input.get(CONF_GITHUB_TOKEN, "")).strip() or None
options = dict(self._config_entry.options)
# Allow clearing the token.
if token:
options[CONF_GITHUB_TOKEN] = token
else:
options.pop(CONF_GITHUB_TOKEN, None)
return self.async_create_entry(title="", data=options)

View File

@@ -0,0 +1,11 @@
"""Constants for Bahmcloud Store."""
from __future__ import annotations
DOMAIN = "bahmcloud_store"
# Fixed store index URL (not user-configurable).
DEFAULT_STORE_URL = "https://git.bahmcloud.de/bahmcloud/ha_store/raw/branch/main/store.yaml"
# Config entry option keys
CONF_GITHUB_TOKEN = "github_token"

View File

@@ -32,6 +32,20 @@ RESTART_REQUIRED_ISSUE_ID = "restart_required"
BACKUP_META_FILENAME = ".bcs_backup_meta.json"
# Optional HACS integrations index (GitHub repositories only).
HACS_INTEGRATIONS_URL = "https://data-v2.hacs.xyz/integration/repositories.json"
HACS_INTEGRATIONS_DATA_URL = "https://data-v2.hacs.xyz/integration/data.json"
HACS_DEFAULT_CATEGORY = "Integrations"
HACS_CACHE_TTL_SECONDS = 60 * 60 * 24 # 24h
# Repo enrichment cache:
# - persists across restarts
# - keeps UI populated (name/description/latest) without blocking startup
REPO_CACHE_TTL_SECONDS = 6 * 60 * 60 # 6h
# Release/tag lists change rarely and can be expensive for some providers (e.g. GitHub rate limits).
# Cache them longer and refresh only on-demand (when the user opens the version selector).
VERSIONS_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60 # 7d
class BCSError(Exception):
"""BCS core error."""
@@ -44,6 +58,7 @@ class BCSInstallError(BCSError):
@dataclass
class BCSConfig:
store_url: str
github_token: str | None = None
@dataclass
@@ -89,9 +104,26 @@ class BCSCore:
self.last_index_hash: str | None = None
self.last_index_loaded_at: float | None = None
# Fast refresh: skip expensive processing when index/custom repos unchanged
self._last_refresh_signature: str | None = None
self._install_lock = asyncio.Lock()
self._installed_cache: dict[str, Any] = {}
# Persistent settings (UI toggles etc.)
self.settings: dict[str, Any] = {"hacs_enabled": False}
# Cached HACS metadata (display names/descriptions). Loaded from storage.
self._hacs_meta_fetched_at: int = 0
self._hacs_meta: dict[str, dict[str, Any]] = {}
self._hacs_meta_lock = asyncio.Lock()
# Persistent per-repo enrichment cache (name/description/latest). Loaded from storage.
self._repo_cache_fetched_at: int = 0
self._repo_cache: dict[str, dict[str, Any]] = {}
self._repo_cache_lock = asyncio.Lock()
self._repo_enrich_task: asyncio.Task | None = None
# Phase F2: backups before install/update
self._backup_root = Path(self.hass.config.path(".bcs_backups"))
self._backup_keep_per_domain: int = 5
@@ -101,9 +133,42 @@ class BCSCore:
self.version = await self._read_manifest_version_async()
await self._refresh_installed_cache()
# Load persistent settings (do not fail startup)
try:
s = await self.storage.get_settings()
if isinstance(s, dict):
self.settings.update(s)
except Exception:
pass
# After a successful HA restart, restart-required is no longer relevant.
self._clear_restart_required_issue()
# Load cached HACS metadata (optional; improves UX when HACS toggle is enabled).
try:
hc = await self.storage.get_hacs_cache()
if isinstance(hc, dict):
self._hacs_meta_fetched_at = int(hc.get("fetched_at") or 0)
repos = hc.get("repos")
if isinstance(repos, dict):
# Normalize to string keys
self._hacs_meta = {str(k): (v if isinstance(v, dict) else {}) for k, v in repos.items()}
except Exception:
self._hacs_meta_fetched_at = 0
self._hacs_meta = {}
# Load persisted per-repo enrichment cache (keeps UI populated after restart).
try:
rc = await self.storage.get_repo_cache()
if isinstance(rc, dict):
self._repo_cache_fetched_at = int(rc.get("fetched_at") or 0)
repos = rc.get("repos")
if isinstance(repos, dict):
self._repo_cache = {str(k): (v if isinstance(v, dict) else {}) for k, v in repos.items()}
except Exception:
self._repo_cache_fetched_at = 0
self._repo_cache = {}
async def _read_manifest_version_async(self) -> str:
def _read() -> str:
try:
@@ -172,17 +237,71 @@ class BCSCore:
data = (self._installed_cache or {}).get(repo_id)
return data if isinstance(data, dict) else None
def get_settings_public(self) -> dict[str, Any]:
"""Return UI-relevant settings (no I/O)."""
return {
"hacs_enabled": bool(self.settings.get("hacs_enabled", False)),
}
async def set_settings(self, updates: dict[str, Any]) -> dict[str, Any]:
"""Persist settings and apply them."""
safe_updates: dict[str, Any] = {}
if "hacs_enabled" in (updates or {}):
safe_updates["hacs_enabled"] = bool(updates.get("hacs_enabled"))
merged = await self.storage.set_settings(safe_updates)
if isinstance(merged, dict):
self.settings.update(merged)
# Reload repo list after changing settings.
await self.full_refresh(source="settings")
return self.get_settings_public()
async def refresh(self) -> None:
index_repos, refresh_seconds = await self._load_index_repos()
self.refresh_seconds = refresh_seconds
hacs_enabled = bool(self.settings.get("hacs_enabled", False))
hacs_repos: list[RepoItem] = []
if hacs_enabled:
try:
hacs_repos = await self._load_hacs_repos()
except Exception as e:
_LOGGER.warning("BCS HACS index load failed: %s", e)
custom_repos = await self.storage.list_custom_repos()
# Fast path: if index + custom repos did not change, skip expensive work.
try:
custom_sig = [(c.id, (c.url or '').strip(), (c.name or '').strip()) for c in (custom_repos or [])]
custom_sig.sort()
hacs_sig = len(hacs_repos) if hacs_enabled else 0
refresh_signature = json.dumps({"index_hash": self.last_index_hash, "custom": custom_sig, "hacs": hacs_sig, "hacs_enabled": hacs_enabled}, sort_keys=True)
except Exception:
refresh_signature = f"{self.last_index_hash}:{len(custom_repos or [])}:{'h' if hacs_enabled else 'n'}:{len(hacs_repos)}"
if self._last_refresh_signature and refresh_signature == self._last_refresh_signature and self.repos:
_LOGGER.debug("BCS refresh fast-path (no repo list changes)")
# Even if the repo list is unchanged, we still want fresh versions/descriptions
# for installed repos and we still want background enrichment to keep the
# overview populated.
if hacs_enabled and self._hacs_meta_needs_refresh():
self.hass.async_create_task(self._refresh_hacs_meta_background())
await self._enrich_installed_only(self.repos)
self._schedule_repo_enrich_background()
return
merged: dict[str, RepoItem] = {}
for item in index_repos:
merged[item.id] = item
for item in hacs_repos:
merged[item.id] = item
for c in custom_repos:
merged[c.id] = RepoItem(
id=c.id,
@@ -194,22 +313,391 @@ class BCSCore:
for r in merged.values():
r.provider = detect_provider(r.url)
await self._enrich_and_resolve(merged)
# Apply cached HACS display metadata immediately (fast UX).
if hacs_enabled and hacs_repos:
self._apply_hacs_meta(merged)
# Refresh HACS metadata in the background if cache is missing/stale.
if self._hacs_meta_needs_refresh():
self.hass.async_create_task(self._refresh_hacs_meta_background())
# Apply persisted per-repo enrichment cache (instant UI after restart).
self._apply_repo_cache(merged)
await self._enrich_installed_only(merged)
self.repos = merged
# Enrich remaining repos in the background and persist results (non-blocking).
self._schedule_repo_enrich_background()
self._last_refresh_signature = refresh_signature
_LOGGER.info(
"BCS refresh complete: repos=%s (index=%s, custom=%s)",
"BCS refresh complete: repos=%s (index=%s, hacs=%s, custom=%s)",
len(self.repos),
len([r for r in self.repos.values() if r.source == "index"]),
len([r for r in self.repos.values() if r.source == "hacs"]),
len([r for r in self.repos.values() if r.source == "custom"]),
)
async def _load_hacs_repos(self) -> list[RepoItem]:
"""Load the official HACS integrations repository list.
This is used as an optional additional source to keep the local store index small.
We only parse owner/repo strings and map them to GitHub URLs.
"""
session = async_get_clientsession(self.hass)
headers = {
"User-Agent": "BahmcloudStore (Home Assistant)",
"Cache-Control": "no-cache, no-store, max-age=0",
"Pragma": "no-cache",
}
async with session.get(HACS_INTEGRATIONS_URL, timeout=60, headers=headers) as resp:
if resp.status != 200:
raise BCSError(f"HACS index returned {resp.status}")
data = await resp.json()
if not isinstance(data, list):
raise BCSError("HACS repositories.json must be a list")
items: list[RepoItem] = []
for entry in data:
if not isinstance(entry, str):
continue
full_name = entry.strip().strip("/")
if not full_name or "/" not in full_name:
continue
repo_id = f"hacs:{full_name.lower()}"
owner = full_name.split("/", 1)[0].strip()
items.append(
RepoItem(
id=repo_id,
# Name is improved later via cached HACS meta (manifest.name).
name=full_name,
url=f"https://github.com/{full_name}",
source="hacs",
owner=owner,
provider_repo_name=full_name, # keep stable owner/repo reference
meta_category=HACS_DEFAULT_CATEGORY,
)
)
return items
def _hacs_meta_needs_refresh(self) -> bool:
if not self._hacs_meta_fetched_at or not self._hacs_meta:
return True
age = int(time.time()) - int(self._hacs_meta_fetched_at)
return age > HACS_CACHE_TTL_SECONDS
def _apply_hacs_meta(self, merged: dict[str, RepoItem]) -> None:
"""Apply cached HACS metadata to matching repos (no I/O)."""
if not self._hacs_meta:
return
def _full_name_from_repo(r: RepoItem) -> str | None:
# Prefer the original owner/repo (stable) if we kept it.
if r.provider_repo_name and "/" in str(r.provider_repo_name):
return str(r.provider_repo_name).strip()
# Fall back to URL path: https://github.com/owner/repo
try:
u = urlparse((r.url or "").strip())
parts = [p for p in (u.path or "").strip("/").split("/") if p]
if len(parts) >= 2:
repo = parts[1]
if repo.endswith(".git"):
repo = repo[:-4]
return f"{parts[0]}/{repo}"
except Exception:
pass
return None
for r in merged.values():
if r.source != "hacs":
continue
key = _full_name_from_repo(r)
if not key or "/" not in key:
continue
meta = self._hacs_meta.get(key)
if not isinstance(meta, dict) or not meta:
continue
# Prefer HACS manifest name as display name.
display_name = meta.get("name")
if isinstance(display_name, str) and display_name.strip():
r.name = display_name.strip()
r.meta_name = display_name.strip()
desc = meta.get("description")
if isinstance(desc, str) and desc.strip():
r.meta_description = desc.strip()
domain = meta.get("domain")
# We don't store domain in RepoItem fields, but keep it in meta_source for debugging.
# (Optional: extend RepoItem later if needed.)
if isinstance(domain, str) and domain.strip():
# Keep under meta_source marker to help identify source.
pass
r.meta_source = r.meta_source or "hacs"
r.meta_category = r.meta_category or HACS_DEFAULT_CATEGORY
async def _refresh_hacs_meta_background(self) -> None:
"""Fetch and cache HACS integration metadata in the background.
Uses the official HACS data endpoint which includes manifest data.
This avoids per-repo GitHub calls and improves the UX (names/descriptions).
"""
async with self._hacs_meta_lock:
# Another task might have refreshed already.
if not self._hacs_meta_needs_refresh():
return
session = async_get_clientsession(self.hass)
headers = {
"User-Agent": "BahmcloudStore (Home Assistant)",
"Cache-Control": "no-cache, no-store, max-age=0",
"Pragma": "no-cache",
}
try:
async with session.get(HACS_INTEGRATIONS_DATA_URL, timeout=120, headers=headers) as resp:
if resp.status != 200:
raise BCSError(f"HACS data.json returned {resp.status}")
data = await resp.json()
except Exception as e:
_LOGGER.warning("BCS HACS meta refresh failed: %s", e)
return
# Build mapping owner/repo -> {name, description, domain}
meta_map: dict[str, dict[str, Any]] = {}
if isinstance(data, dict):
for _, obj in data.items():
if not isinstance(obj, dict):
continue
full_name = obj.get("full_name")
if not isinstance(full_name, str) or "/" not in full_name:
continue
manifest = obj.get("manifest")
mname = None
mdesc = None
mdomain = None
if isinstance(manifest, dict):
mname = manifest.get("name")
mdesc = manifest.get("description")
mdomain = manifest.get("domain")
entry: dict[str, Any] = {}
if isinstance(mname, str) and mname.strip():
entry["name"] = mname.strip()
if isinstance(mdesc, str) and mdesc.strip():
entry["description"] = mdesc.strip()
if isinstance(mdomain, str) and mdomain.strip():
entry["domain"] = mdomain.strip()
if entry:
meta_map[full_name.strip()] = entry
self._hacs_meta = meta_map
self._hacs_meta_fetched_at = int(time.time())
try:
await self.storage.set_hacs_cache({
"fetched_at": self._hacs_meta_fetched_at,
"repos": self._hacs_meta,
})
except Exception:
_LOGGER.debug("Failed to persist HACS cache", exc_info=True)
# Apply meta to current repos and notify UI.
try:
self._apply_hacs_meta(self.repos)
except Exception:
pass
_LOGGER.info("BCS HACS metadata cached: repos=%s", len(self._hacs_meta))
self.signal_updated()
# ---------- Persistent per-repo enrichment cache ----------
def _apply_repo_cache(self, merged: dict[str, RepoItem]) -> None:
"""Apply persisted per-repo enrichment cache to RepoItem objects.
This is a fast, no-I/O operation used to keep the UI populated immediately
after a Home Assistant restart.
"""
if not self._repo_cache:
return
for repo_id, r in merged.items():
cached = self._repo_cache.get(str(repo_id))
if not isinstance(cached, dict) or not cached:
continue
cached_url = cached.get("url")
if isinstance(cached_url, str) and cached_url and r.url and cached_url != r.url:
# Repo id reused for different URL; ignore.
continue
# Fill gaps only; never override fresh values from current refresh.
if (not r.name or r.name == r.url) and isinstance(cached.get("name"), str) and cached.get("name"):
r.name = str(cached.get("name"))
for attr, key in (
("owner", "owner"),
("provider_repo_name", "provider_repo_name"),
("provider_description", "provider_description"),
("default_branch", "default_branch"),
("latest_version", "latest_version"),
("latest_version_source", "latest_version_source"),
("meta_source", "meta_source"),
("meta_name", "meta_name"),
("meta_description", "meta_description"),
("meta_category", "meta_category"),
("meta_author", "meta_author"),
("meta_maintainer", "meta_maintainer"),
):
if getattr(r, attr, None):
continue
v = cached.get(key)
if v is None:
continue
if isinstance(v, str):
vv = v.strip()
if vv:
setattr(r, attr, vv)
def _repo_needs_enrich(self, repo_id: str, r: RepoItem) -> bool:
"""Return True if this repo should be enriched in background."""
cached = self._repo_cache.get(str(repo_id)) if isinstance(self._repo_cache, dict) else None
ts = 0
if isinstance(cached, dict):
ts = int(cached.get("ts") or 0)
# Background enrichment should be a one-time best-effort pass.
# If we already attempted it once for this repo, do not keep retrying on every refresh.
# On-demand (opening repo details / version selector) can still refresh and persist.
if bool(cached.get("bg_done")):
return False
# Missing key fields -> enrich.
if not r.latest_version:
return True
if not (r.meta_description or r.provider_description):
return True
# Stale cache -> enrich.
if ts <= 0:
return True
age = int(time.time()) - ts
return age > REPO_CACHE_TTL_SECONDS
def _update_repo_cache_from_repo(self, repo_id: str, r: RepoItem) -> None:
self._repo_cache[str(repo_id)] = {
"ts": int(time.time()),
"url": r.url,
"name": r.name,
"owner": r.owner,
"provider_repo_name": r.provider_repo_name,
"provider_description": r.provider_description,
"default_branch": r.default_branch,
"latest_version": r.latest_version,
"latest_version_source": r.latest_version_source,
"meta_source": r.meta_source,
"meta_name": r.meta_name,
"meta_description": r.meta_description,
"meta_category": r.meta_category,
"meta_author": r.meta_author,
"meta_maintainer": r.meta_maintainer,
}
def _schedule_repo_enrich_background(self) -> None:
"""Schedule background enrichment for repo details.
This is non-blocking and safe to call repeatedly.
"""
if self._repo_enrich_task and not self._repo_enrich_task.done():
return
self._repo_enrich_task = self.hass.async_create_task(self._repo_enrich_background())
async def _repo_enrich_background(self) -> None:
# Small delay to avoid competing with critical startup work.
await asyncio.sleep(2)
repo_ids = list(self.repos.keys())
updated = 0
# Keep it conservative to avoid rate limits.
sem = asyncio.Semaphore(4)
async def process_one(repo_id: str) -> None:
nonlocal updated
r = self.repos.get(repo_id)
if not r:
return
if not self._repo_needs_enrich(repo_id, r):
return
async with sem:
try:
await self._enrich_one_repo(r)
except Exception:
_LOGGER.debug("BCS background enrich failed for %s", repo_id, exc_info=True)
# Mark as attempted so we don't keep hammering the provider.
async with self._repo_cache_lock:
cached = self._repo_cache.setdefault(str(repo_id), {})
if isinstance(cached, dict):
cached["bg_done"] = True
cached["bg_done_ts"] = int(time.time())
return
async with self._repo_cache_lock:
self._update_repo_cache_from_repo(repo_id, r)
cached = self._repo_cache.setdefault(str(repo_id), {})
if isinstance(cached, dict):
cached["bg_done"] = True
cached["bg_done_ts"] = int(time.time())
updated += 1
# Process sequentially but allow limited concurrency.
tasks: list[asyncio.Task] = []
for repo_id in repo_ids:
tasks.append(self.hass.async_create_task(process_one(repo_id)))
# Flush in batches to limit memory/connection churn.
if len(tasks) >= 25:
await asyncio.gather(*tasks, return_exceptions=True)
tasks = []
if updated:
await self._persist_repo_cache()
self.signal_updated()
await asyncio.sleep(0)
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
if updated:
await self._persist_repo_cache()
self.signal_updated()
async def _persist_repo_cache(self) -> None:
async with self._repo_cache_lock:
payload = {
"fetched_at": int(time.time()),
"repos": self._repo_cache,
}
try:
await self.storage.set_repo_cache(payload)
except Exception:
_LOGGER.debug("BCS failed to persist repo cache", exc_info=True)
async def _enrich_and_resolve(self, merged: dict[str, RepoItem]) -> None:
sem = asyncio.Semaphore(6)
async def process_one(r: RepoItem) -> None:
async with sem:
info: RepoInfo = await fetch_repo_info(self.hass, r.url)
info: RepoInfo = await fetch_repo_info(self.hass, r.url, github_token=self.config.github_token)
r.provider = info.provider or r.provider
r.owner = info.owner or r.owner
@@ -238,6 +726,154 @@ class BCSCore:
await asyncio.gather(*(process_one(r) for r in merged.values()), return_exceptions=True)
async def _enrich_installed_only(self, merged: dict[str, RepoItem]) -> None:
"""Enrich only installed repos (fast refresh for large indexes).
This keeps the backend responsive even with thousands of repositories.
Details for non-installed repos are fetched on-demand.
"""
installed_map: dict[str, Any] = getattr(self, "_installed_cache", {}) or {}
if not isinstance(installed_map, dict) or not installed_map:
return
to_process: list[RepoItem] = []
for rid in installed_map.keys():
r = merged.get(str(rid))
if r:
to_process.append(r)
if not to_process:
return
sem = asyncio.Semaphore(6)
async def process_one(r: RepoItem) -> None:
async with sem:
await self._enrich_one_repo(r)
await asyncio.gather(*(process_one(r) for r in to_process), return_exceptions=True)
async def _enrich_one_repo(self, r: RepoItem) -> None:
"""Fetch provider info + metadata for a single repo item."""
info: RepoInfo = await fetch_repo_info(self.hass, r.url, github_token=self.config.github_token)
r.provider = info.provider or r.provider
r.owner = info.owner or r.owner
r.provider_repo_name = info.repo_name
r.provider_description = info.description
r.default_branch = info.default_branch or r.default_branch
r.latest_version = info.latest_version
r.latest_version_source = info.latest_version_source
md: RepoMetadata = await fetch_repo_metadata(self.hass, r.url, r.default_branch)
r.meta_source = md.source
if md.name:
r.meta_name = md.name
r.name = md.name
r.meta_description = md.description
if md.category:
r.meta_category = md.category
r.meta_author = md.author
r.meta_maintainer = md.maintainer
# Keep a stable name fallback
if not r.name:
r.name = r.provider_repo_name or r.url
async def ensure_repo_details(self, repo_id: str) -> RepoItem | None:
"""Ensure provider/meta/latest fields are loaded for a repo.
Used by the UI when a repo detail view is opened.
"""
r = self.get_repo(repo_id)
if not r:
return None
# Consider it enriched only if we already have a latest_version and at least
# some descriptive info (meta/provider). HACS repos often have meta_source set
# early, but still need provider latest_version.
if r.latest_version and (r.provider_description or r.meta_description or r.meta_source):
return r
try:
await self._enrich_one_repo(r)
# Persist the newly fetched details so they survive a Home Assistant restart.
async with self._repo_cache_lock:
self._update_repo_cache_from_repo(repo_id, r)
await self._persist_repo_cache()
except Exception:
_LOGGER.debug("BCS ensure_repo_details failed for %s", repo_id, exc_info=True)
return r
async def list_repo_versions(
self,
repo_id: str,
*,
limit: int = 20,
force_refresh: bool = False,
) -> list[dict[str, Any]]:
repo = self.get_repo(repo_id)
if not repo:
return []
# Prefer cached version lists to avoid hammering provider APIs (notably GitHub unauthenticated
# rate limits). However, if the cached list is clearly a degraded fallback (e.g. only
# "Latest" + "Branch"), we treat it as stale and retry immediately when the user requests
# versions again.
cached = None
cached_ts = 0
async with self._repo_cache_lock:
cached = self._repo_cache.get(str(repo_id)) if isinstance(self._repo_cache, dict) else None
if isinstance(cached, dict):
cached_ts = int(cached.get("versions_ts", 0) or 0)
now = int(time.time())
cached_versions = list(cached.get("versions") or []) if isinstance(cached, dict) else []
cache_fresh = (now - cached_ts) < VERSIONS_CACHE_TTL_SECONDS
# Cache hit if it's fresh and not degraded, unless the caller explicitly wants a refresh.
if (
not force_refresh
and cached_versions
and cache_fresh
and len(cached_versions) > 2
):
return cached_versions
try:
versions = await fetch_repo_versions(
self.hass,
repo.url,
provider=repo.provider,
default_branch=repo.default_branch,
limit=limit,
github_token=self.config.github_token,
)
except Exception:
versions = []
# If the provider fetch returned only the basic fallbacks ("Latest" + "Branch") but we have
# a previously cached richer list, keep using the cached list.
if (
isinstance(cached, dict)
and cached.get("versions")
and len(list(cached.get("versions") or [])) > 2
and len(versions) <= 2
):
return list(cached.get("versions") or [])
# Persist whatever we got (even if small) to avoid repeated calls when rate-limited.
async with self._repo_cache_lock:
entry = self._repo_cache.setdefault(str(repo_id), {}) if isinstance(self._repo_cache, dict) else {}
if isinstance(entry, dict):
entry["versions"] = versions
entry["versions_ts"] = now
await self._persist_repo_cache()
return versions
def _add_cache_buster(self, url: str) -> str:
parts = urlsplit(url)
q = dict(parse_qsl(parts.query, keep_blank_values=True))
@@ -329,6 +965,7 @@ class BCSCore:
name=name,
url=repo_url,
source="index",
meta_category=str(r.get("category")) if r.get("category") else None,
)
)
@@ -415,23 +1052,6 @@ class BCSCore:
default_branch=repo.default_branch,
)
async def list_repo_versions(self, repo_id: str, *, limit: int = 20) -> list[dict[str, str]]:
"""List installable versions/refs for a repo.
This is used by the UI to allow selecting an older tag/release.
"""
repo = self.get_repo(repo_id)
if not repo:
raise BCSInstallError(f"repo_id not found: {repo_id}")
return await fetch_repo_versions(
self.hass,
repo.url,
provider=repo.provider,
default_branch=repo.default_branch,
limit=limit,
)
def _pick_ref_for_install(self, repo: RepoItem) -> str:
if repo.latest_version and str(repo.latest_version).strip():
return str(repo.latest_version).strip()

View File

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

View File

@@ -18,6 +18,12 @@ class BahmcloudStorePanel extends HTMLElement {
this._filter = "all"; // all|installed|not_installed|updates|custom
this._sort = "az"; // az|updates_first|installed_first
// Source filter (all|bcs|hacs|custom)
this._sourceFilter = "all";
// HACS toggle (settings)
this._hacsEnabled = false;
this._detailRepoId = null;
this._detailRepo = null;
this._readmeLoading = false;
@@ -50,6 +56,10 @@ class BahmcloudStorePanel extends HTMLElement {
this._versionsCache = {}; // repo_id -> [{ref,label,source}, ...]
this._versionsLoadingRepoId = null;
this._selectedVersionByRepoId = {}; // repo_id -> ref ("" means latest)
// History handling (mobile back button should go back to list, not exit panel)
this._historyBound = false;
this._handlingPopstate = false;
}
set hass(hass) {
@@ -57,10 +67,43 @@ class BahmcloudStorePanel extends HTMLElement {
if (!this._rendered) {
this._rendered = true;
this._render();
this._ensureHistory();
this._load();
}
}
_ensureHistory() {
if (this._historyBound) return;
this._historyBound = true;
try {
// Keep an internal history state for this panel.
const current = window.history.state || {};
if (!current || current.__bcs !== true) {
window.history.replaceState({ __bcs: true, view: "store" }, "");
}
} catch (e) {
// ignore
}
window.addEventListener("popstate", (ev) => {
const st = ev?.state;
if (!st || st.__bcs !== true) return;
this._handlingPopstate = true;
try {
const view = st.view || "store";
if (view === "detail" && st.repo_id) {
this._openRepoDetail(st.repo_id, false);
} else {
this._closeDetail(false);
}
} finally {
this._handlingPopstate = false;
}
});
}
async _load() {
if (!this._hass) return;
@@ -72,6 +115,12 @@ class BahmcloudStorePanel extends HTMLElement {
const data = await this._hass.callApi("get", "bcs");
this._data = data;
// Persistent settings (e.g. HACS toggle)
this._hacsEnabled = !!data?.settings?.hacs_enabled;
// Sync settings from backend (e.g. HACS toggle)
this._hacsEnabled = !!data?.settings?.hacs_enabled;
if (this._view === "detail" && this._detailRepoId && Array.isArray(data?.repos)) {
const fresh = data.repos.find((r) => this._safeId(r?.id) === this._detailRepoId);
if (fresh) this._detailRepo = fresh;
@@ -84,6 +133,19 @@ class BahmcloudStorePanel extends HTMLElement {
}
}
async _setSettings(updates) {
if (!this._hass) return;
try {
const resp = await this._hass.callApi("post", "bcs/settings", updates || {});
if (resp?.ok) {
this._hacsEnabled = !!resp?.settings?.hacs_enabled;
}
} catch (e) {
// Do not fail UI for settings.
this._error = e?.message ? String(e.message) : String(e);
}
}
async _refreshAll() {
if (!this._hass) return;
if (this._refreshing) return;
@@ -308,21 +370,15 @@ class BahmcloudStorePanel extends HTMLElement {
}
_goBack() {
if (this._view === "detail") {
this._view = "store";
this._detailRepoId = null;
this._detailRepo = null;
this._readmeText = null;
this._readmeHtml = null;
this._readmeError = null;
this._readmeExpanded = false;
this._update();
return;
}
try {
// Prefer browser history so mobile back behaves as expected.
history.back();
} catch (_) {
window.location.href = "/";
if (this._view === "detail") {
this._closeDetail(true);
} else {
window.location.href = "/";
}
}
}
@@ -368,11 +424,15 @@ class BahmcloudStorePanel extends HTMLElement {
}
}
_openRepoDetail(repoId) {
_openRepoDetail(repoId, pushHistory = true) {
const repos = Array.isArray(this._data?.repos) ? this._data.repos : [];
const repo = repos.find((r) => this._safeId(r?.id) === repoId);
if (!repo) return;
if (pushHistory) {
this._pushHistory({ view: "detail", repo_id: repoId });
}
this._view = "detail";
this._detailRepoId = repoId;
this._detailRepo = repo;
@@ -389,10 +449,29 @@ class BahmcloudStorePanel extends HTMLElement {
}
this._update();
this._loadRepoDetails(repoId);
this._loadReadme(repoId);
this._loadVersions(repoId);
}
async _loadRepoDetails(repoId) {
if (!this._hass || !repoId) return;
try {
const resp = await this._hass.callApi("get", `bcs/repo?repo_id=${encodeURIComponent(repoId)}`);
if (resp?.ok && resp.repo) {
this._detailRepo = resp.repo;
// Also update the cached list item if present
const repos = Array.isArray(this._data?.repos) ? this._data.repos : [];
const idx = repos.findIndex((r) => this._safeId(r?.id) === repoId);
if (idx >= 0) repos[idx] = resp.repo;
this._update();
}
} catch (e) {
// ignore: details are optional
}
}
async _loadVersions(repoId) {
if (!this._hass) return;
if (!repoId) return;
@@ -542,6 +621,24 @@ class BahmcloudStorePanel extends HTMLElement {
box-shadow: 0 0 0 2px rgba(30,136,229,.15);
}
.toggle{
display:inline-flex;
align-items:center;
gap:8px;
padding:10px 12px;
border-radius:14px;
border:1px solid var(--divider-color);
background: var(--card-background-color);
color: var(--primary-text-color);
user-select:none;
cursor:pointer;
}
.toggle input{
margin:0;
width:18px;
height:18px;
}
button{
padding:10px 12px;
border-radius:14px;
@@ -840,6 +937,11 @@ class BahmcloudStorePanel extends HTMLElement {
const cat = this._safeText(r?.category) || "";
if (this._category !== "all" && this._category !== cat) return false;
// Source filter
if (this._sourceFilter === "bcs" && r?.source !== "index") return false;
if (this._sourceFilter === "hacs" && r?.source !== "hacs") return false;
if (this._sourceFilter === "custom" && r?.source !== "custom") return false;
const latest = this._safeText(r?.latest_version);
const installed = this._asBoolStrict(r?.installed);
const installedVersion = this._safeText(r?.installed_version);
@@ -894,7 +996,11 @@ class BahmcloudStorePanel extends HTMLElement {
const updateAvailable = installed && !!latest && (!installedVersion || latest !== installedVersion);
const badges = [];
if (r?.source === "custom") badges.push("Custom");
// Source badges
if (r?.source === "index") badges.push("BCS Official");
else if (r?.source === "hacs") badges.push("HACS");
else if (r?.source === "custom") badges.push("Custom");
if (installed) badges.push("Installed");
if (updateAvailable) badges.push("Update");
@@ -920,6 +1026,18 @@ class BahmcloudStorePanel extends HTMLElement {
return `
<div class="filters">
<input id="q" placeholder="Search…" value="${this._esc(this._search)}" />
<label class="toggle" title="Show official HACS repositories">
<input id="hacs_toggle" type="checkbox" ${this._hacsEnabled ? "checked" : ""} />
<span>HACS</span>
</label>
<select id="src">
<option value="all" ${this._sourceFilter === "all" ? "selected" : ""}>All sources</option>
<option value="bcs" ${this._sourceFilter === "bcs" ? "selected" : ""}>BCS Official</option>
<option value="hacs" ${this._sourceFilter === "hacs" ? "selected" : ""}>HACS</option>
<option value="custom" ${this._sourceFilter === "custom" ? "selected" : ""}>Custom</option>
</select>
<select id="cat">
<option value="all">All categories</option>
${categories.map((c) => `<option value="${this._esc(c)}" ${this._category === c ? "selected" : ""}>${this._esc(c)}</option>`).join("")}
@@ -953,6 +1071,8 @@ class BahmcloudStorePanel extends HTMLElement {
const cat = root.getElementById("cat");
const filter = root.getElementById("filter");
const sort = root.getElementById("sort");
const src = root.getElementById("src");
const hacsToggle = root.getElementById("hacs_toggle");
if (q) {
q.addEventListener("input", (e) => {
@@ -979,12 +1099,51 @@ class BahmcloudStorePanel extends HTMLElement {
});
}
if (src) {
src.addEventListener("change", (e) => {
this._sourceFilter = e?.target?.value || "all";
this._update();
});
}
if (hacsToggle) {
hacsToggle.addEventListener("change", async (e) => {
const enabled = !!e?.target?.checked;
this._hacsEnabled = enabled;
this._update();
await this._setSettings({ hacs_enabled: enabled });
await this._load();
});
}
root.querySelectorAll("[data-open]").forEach((el) => {
const id = el.getAttribute("data-open");
el.addEventListener("click", () => this._openRepoDetail(id));
el.addEventListener("click", () => this._openRepoDetail(id, true));
});
}
_pushHistory(state) {
if (this._handlingPopstate) return;
try {
window.history.pushState({ __bcs: true, ...(state || {}) }, "");
} catch (e) {
// ignore
}
}
_closeDetail(pushHistory = true) {
this._view = "store";
this._detailRepoId = null;
this._detailRepo = null;
this._readmeText = null;
this._readmeHtml = null;
this._readmeError = null;
this._readmeExpanded = false;
this._readmeCanToggle = false;
if (pushHistory) this._pushHistory({ view: "store" });
this._update();
}
_renderAbout() {
return `
<div class="card">

View File

@@ -189,9 +189,11 @@ async def _github_latest_version_redirect(hass: HomeAssistant, owner: str, repo:
return None, None
async def _github_latest_version_api(hass: HomeAssistant, owner: str, repo: str) -> tuple[str | None, str | None]:
async def _github_latest_version_api(
hass: HomeAssistant, owner: str, repo: str, *, github_token: str | None = None
) -> tuple[str | None, str | None]:
session = async_get_clientsession(hass)
headers = {"Accept": "application/vnd.github+json", "User-Agent": UA}
headers = _github_headers(github_token)
data, status = await _safe_json(session, f"https://api.github.com/repos/{owner}/{repo}/releases/latest", headers=headers)
if isinstance(data, dict) and data.get("tag_name"):
@@ -217,12 +219,14 @@ async def _github_latest_version_api(hass: HomeAssistant, owner: str, repo: str)
return None, None
async def _github_latest_version(hass: HomeAssistant, owner: str, repo: str) -> tuple[str | None, str | None]:
async def _github_latest_version(
hass: HomeAssistant, owner: str, repo: str, *, github_token: str | None = None
) -> tuple[str | None, str | None]:
tag, src = await _github_latest_version_redirect(hass, owner, repo)
if tag:
return tag, src
tag, src = await _github_latest_version_api(hass, owner, repo)
tag, src = await _github_latest_version_api(hass, owner, repo, github_token=github_token)
if tag:
return tag, src
@@ -316,7 +320,16 @@ async def _gitlab_latest_version(
return None, None
async def fetch_repo_info(hass: HomeAssistant, repo_url: str) -> RepoInfo:
def _github_headers(github_token: str | None = None) -> dict:
headers = {"Accept": "application/vnd.github+json", "User-Agent": UA}
token = (github_token or "").strip()
if token:
# PAT or fine-grained token
headers["Authorization"] = f"Bearer {token}"
return headers
async def fetch_repo_info(hass: HomeAssistant, repo_url: str, *, github_token: str | None = None) -> RepoInfo:
provider = detect_provider(repo_url)
owner, repo = _split_owner_repo(repo_url)
@@ -337,7 +350,7 @@ async def fetch_repo_info(hass: HomeAssistant, repo_url: str) -> RepoInfo:
try:
if provider == "github":
headers = {"Accept": "application/vnd.github+json", "User-Agent": UA}
headers = _github_headers(github_token)
data, status = await _safe_json(session, f"https://api.github.com/repos/{owner}/{repo}", headers=headers)
if isinstance(data, dict):
@@ -356,7 +369,7 @@ async def fetch_repo_info(hass: HomeAssistant, repo_url: str) -> RepoInfo:
if desc:
info.description = desc
ver, src = await _github_latest_version(hass, owner, repo)
ver, src = await _github_latest_version(hass, owner, repo, github_token=github_token)
info.latest_version = ver
info.latest_version_source = src
return info
@@ -514,6 +527,7 @@ async def fetch_repo_versions(
provider: str | None = None,
default_branch: str | None = None,
limit: int = 20,
github_token: str | None = None,
) -> list[dict[str, str]]:
"""List available versions/refs for a repository.
@@ -523,7 +537,7 @@ async def fetch_repo_versions(
- source: release|tag|branch
Notes:
- Uses public endpoints (no tokens) for public repositories.
- Uses provider APIs; for GitHub we include the configured token (if any) to avoid unauthenticated rate limits.
- We prefer releases first (if available), then tags.
"""
@@ -541,6 +555,8 @@ async def fetch_repo_versions(
session = async_get_clientsession(hass)
headers = {"User-Agent": UA}
if prov == "github":
headers = _github_headers(github_token)
out: list[dict[str, str]] = []
seen: set[str] = set()
@@ -559,11 +575,13 @@ async def fetch_repo_versions(
try:
if prov == "github":
# Releases
gh_headers = {"Accept": "application/vnd.github+json", "User-Agent": UA}
# Releases (prefer these over tags)
# Use the configured GitHub token (if any) to avoid unauthenticated rate limits.
gh_headers = _github_headers(github_token)
per_page = max(1, min(int(limit), 100))
data, _ = await _safe_json(
session,
f"https://api.github.com/repos/{owner}/{repo}/releases?per_page={int(limit)}",
f"https://api.github.com/repos/{owner}/{repo}/releases?per_page={per_page}",
headers=gh_headers,
)
if isinstance(data, list):
@@ -581,7 +599,7 @@ async def fetch_repo_versions(
# Tags
data, _ = await _safe_json(
session,
f"https://api.github.com/repos/{owner}/{repo}/tags?per_page={int(limit)}",
f"https://api.github.com/repos/{owner}/{repo}/tags?per_page={per_page}",
headers=gh_headers,
)
if isinstance(data, list):

View File

@@ -36,6 +36,9 @@ class BCSStorage:
Keys:
- custom_repos: list of manually added repositories
- installed_repos: mapping repo_id -> installed metadata
- settings: persistent user settings (e.g. toggles in the UI)
- hacs_cache: cached HACS metadata to improve UX (display names/descriptions)
- repo_cache: cached per-repo enrichment (names/descriptions/versions) to keep the UI populated after restart
"""
def __init__(self, hass: HomeAssistant) -> None:
@@ -53,8 +56,92 @@ class BCSStorage:
if "installed_repos" not in data or not isinstance(data.get("installed_repos"), dict):
data["installed_repos"] = {}
if "settings" not in data or not isinstance(data.get("settings"), dict):
data["settings"] = {}
if "hacs_cache" not in data or not isinstance(data.get("hacs_cache"), dict):
data["hacs_cache"] = {}
if "repo_cache" not in data or not isinstance(data.get("repo_cache"), dict):
data["repo_cache"] = {}
return data
async def get_repo_cache(self) -> dict[str, Any]:
"""Return cached per-repo enrichment data.
Shape:
{
"fetched_at": <unix_ts>,
"repos": {
"<repo_id>": {
"ts": <unix_ts>,
"url": "...",
"name": "...",
"provider_description": "...",
"meta_name": "...",
"meta_description": "...",
"meta_category": "...",
"meta_source": "...",
"latest_version": "...",
"latest_version_source": "...",
"default_branch": "...",
"owner": "...",
"provider_repo_name": "..."
}
}
}
"""
data = await self._load()
cache = data.get("repo_cache", {})
return cache if isinstance(cache, dict) else {}
async def set_repo_cache(self, cache: dict[str, Any]) -> None:
"""Persist cached per-repo enrichment data."""
data = await self._load()
data["repo_cache"] = cache if isinstance(cache, dict) else {}
await self._save(data)
async def get_hacs_cache(self) -> dict[str, Any]:
"""Return cached HACS metadata.
Shape:
{
"fetched_at": <unix_ts>,
"repos": {"owner/repo": {"name": "...", "description": "...", "domain": "..."}}
}
"""
data = await self._load()
cache = data.get("hacs_cache", {})
return cache if isinstance(cache, dict) else {}
async def set_hacs_cache(self, cache: dict[str, Any]) -> None:
"""Persist cached HACS metadata."""
data = await self._load()
data["hacs_cache"] = cache if isinstance(cache, dict) else {}
await self._save(data)
async def get_settings(self) -> dict[str, Any]:
"""Return persistent settings.
Currently used for UI/behavior toggles.
"""
data = await self._load()
settings = data.get("settings", {})
return settings if isinstance(settings, dict) else {}
async def set_settings(self, updates: dict[str, Any]) -> dict[str, Any]:
"""Update persistent settings and return the merged settings."""
data = await self._load()
settings = data.get("settings", {})
if not isinstance(settings, dict):
settings = {}
for k, v in (updates or {}).items():
settings[str(k)] = v
data["settings"] = settings
await self._save(data)
return settings
async def _save(self, data: dict[str, Any]) -> None:
await self._store.async_save(data)

View File

@@ -1,4 +1,29 @@
{
"config": {
"abort": {
"single_instance_allowed": "Only one Bahmcloud Store instance can be configured."
},
"step": {
"user": {
"title": "Bahmcloud Store",
"description": "Bahmcloud Store uses a fixed official store index. You can optionally add a GitHub token to increase API rate limits.",
"data": {
"github_token": "GitHub token (optional)"
}
}
}
},
"options": {
"step": {
"init": {
"title": "Bahmcloud Store Options",
"description": "Optionally set or clear your GitHub token to reduce rate limiting.",
"data": {
"github_token": "GitHub token (optional)"
}
}
}
},
"issues": {
"restart_required": {
"title": "Restart required",
@@ -15,4 +40,4 @@
}
}
}
}
}

View File

@@ -10,11 +10,21 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity import EntityCategory
from .core import DOMAIN, SIGNAL_UPDATED, BCSCore
from .const import DOMAIN
from .core import SIGNAL_UPDATED, BCSCore
_LOGGER = logging.getLogger(__name__)
def _get_core(hass: HomeAssistant) -> BCSCore | None:
data = hass.data.get(DOMAIN)
if isinstance(data, dict):
c = data.get("_core")
return c if isinstance(c, BCSCore) else None
# Backwards compatibility (older versions used hass.data[DOMAIN] = core)
return data if isinstance(data, BCSCore) else None
def _pretty_repo_name(core: BCSCore, repo_id: str) -> str:
"""Return a human-friendly name for a repo update entity."""
try:
@@ -146,7 +156,7 @@ async def async_setup_platform(
discovery_info=None,
):
"""Set up BCS update entities."""
core: BCSCore | None = hass.data.get(DOMAIN)
core: BCSCore | None = _get_core(hass)
if not core:
_LOGGER.debug("BCS core not available, skipping update platform setup")
return
@@ -159,4 +169,13 @@ async def async_setup_platform(
def _handle_update() -> None:
_sync_entities(core, entities, async_add_entities)
async_dispatcher_connect(hass, SIGNAL_UPDATED, _handle_update)
async_dispatcher_connect(hass, SIGNAL_UPDATED, _handle_update)
async def async_setup_entry(
hass: HomeAssistant,
entry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up BCS update entities from a config entry."""
await async_setup_platform(hass, {}, async_add_entities, None)

View File

@@ -215,7 +215,12 @@ class BCSApiView(HomeAssistantView):
async def get(self, request: web.Request) -> web.Response:
return web.json_response(
{"ok": True, "version": self.core.version, "repos": self.core.list_repos_public()}
{
"ok": True,
"version": self.core.version,
"settings": self.core.get_settings_public(),
"repos": self.core.list_repos_public(),
}
)
async def post(self, request: web.Request) -> web.Response:
@@ -248,6 +253,37 @@ class BCSApiView(HomeAssistantView):
return web.json_response({"ok": False, "message": "Unknown operation"}, status=400)
class BCSSettingsView(HomeAssistantView):
"""Persistent UI settings (e.g. toggles)."""
url = "/api/bcs/settings"
name = "api:bcs_settings"
requires_auth = True
def __init__(self, core: Any) -> None:
self.core: BCSCore = core
async def get(self, request: web.Request) -> web.Response:
return web.json_response({"ok": True, "settings": self.core.get_settings_public()})
async def post(self, request: web.Request) -> web.Response:
try:
data = await request.json()
except Exception:
data = {}
updates: dict[str, Any] = {}
if "hacs_enabled" in data:
updates["hacs_enabled"] = bool(data.get("hacs_enabled"))
try:
settings = await self.core.set_settings(updates)
return web.json_response({"ok": True, "settings": settings})
except Exception as e:
_LOGGER.exception("BCS set settings failed: %s", e)
return web.json_response({"ok": False, "message": str(e) or "Failed"}, status=500)
class BCSCustomRepoView(HomeAssistantView):
url = "/api/bcs/custom_repo"
name = "api:bcs_custom_repo"
@@ -441,4 +477,56 @@ class BCSRestartView(HomeAssistantView):
return web.json_response({"ok": True})
except Exception as e:
_LOGGER.exception("BCS restart failed: %s", e)
return web.json_response({"ok": False, "message": str(e) or "Restart failed"}, status=500)
return web.json_response({"ok": False, "message": str(e) or "Restart failed"}, status=500)
class BCSRepoDetailView(HomeAssistantView):
url = "/api/bcs/repo"
name = "api:bcs_repo"
requires_auth = True
def __init__(self, core: Any) -> None:
self.core: BCSCore = core
async def get(self, request: web.Request) -> web.Response:
repo_id = (request.query.get("repo_id") or "").strip()
if not repo_id:
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
try:
repo = await self.core.ensure_repo_details(repo_id)
if not repo:
return web.json_response({"ok": False, "message": "Repo not found"}, status=404)
inst = self.core.get_installed(repo_id) or {}
installed = bool(inst)
domains = inst.get("domains") or []
if not isinstance(domains, list):
domains = []
return web.json_response({
"ok": True,
"repo": {
"id": repo.id,
"name": repo.name,
"url": repo.url,
"source": repo.source,
"owner": repo.owner,
"provider": repo.provider,
"repo_name": repo.provider_repo_name,
"description": repo.provider_description or repo.meta_description,
"default_branch": repo.default_branch,
"latest_version": repo.latest_version,
"latest_version_source": repo.latest_version_source,
"category": repo.meta_category,
"meta_author": repo.meta_author,
"meta_maintainer": repo.meta_maintainer,
"meta_source": repo.meta_source,
"installed": installed,
"installed_version": inst.get("installed_version"),
"installed_manifest_version": inst.get("installed_manifest_version"),
"installed_domains": domains,
}
}, status=200)
except Exception as e:
_LOGGER.exception("BCS repo details failed: %s", e)
return web.json_response({"ok": False, "message": str(e) or "Repo details failed"}, status=500)