32 Commits
0.6.5 ... 0.6.9

Author SHA1 Message Date
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
14 changed files with 1333 additions and 72 deletions

View File

@@ -11,6 +11,75 @@ Sections:
---
## [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

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,35 +3,51 @@ 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, async_call_later
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.helpers.discovery import async_load_platform
from .core import BCSCore, BCSConfig, BCSError
from .config_flow import CONF_GITHUB_TOKEN
_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"
CONF_STORE_URL = "store_url"
PLATFORMS: list[str] = ["update"]
DATA_ENTRY = f"{DOMAIN}_entry_runtime"
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))
YAML configuration is intentionally not supported.
Setup must happen via the UI (Config Flow).
"""
if DOMAIN in (config or {}):
_LOGGER.warning(
"BCS does not support configuration.yaml setup. "
"Please remove 'bahmcloud_store:' from configuration.yaml and set up the integration via Settings -> Devices & Services."
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Bahmcloud Store from a config entry."""
token = (entry.options.get(CONF_GITHUB_TOKEN) or "").strip() or None
core = BCSCore(hass, BCSConfig(store_url=DEFAULT_STORE_URL, github_token=token))
hass.data[DOMAIN] = 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)
# Register HTTP views + static assets
from .views import (
StaticAssetsView,
BCSApiView,
@@ -62,25 +78,29 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
hass.http.register_view(BCSRestoreView(core))
hass.http.register_view(BCSRestartView(core))
# Sidebar panel
await async_register_panel(
hass,
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=107",
module_url="/api/bahmcloud_store_static/panel.js?v=109",
sidebar_title="Bahmcloud Store",
sidebar_icon="mdi:store",
require_admin=True,
config={},
)
# Forward platform setup (Update entities)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
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 Home Assistant startup. Schedule the initial refresh after HA started.
# Do not block HA startup: schedule refresh after HA started.
def _on_ha_started(_event) -> None:
async_call_later(hass, 30, _do_startup_refresh)
@@ -95,6 +115,30 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
_LOGGER.exception("Unexpected error during periodic refresh: %s", e)
interval_seconds = int(getattr(core, "refresh_seconds", 300) or 300)
async_track_time_interval(hass, periodic, timedelta(seconds=interval_seconds))
unsub = async_track_time_interval(hass, periodic, timedelta(seconds=interval_seconds))
return True
# Store unload callbacks safely
runtimes = hass.data.setdefault(DATA_ENTRY, {})
runtimes[entry.entry_id] = {"unsub_interval": unsub}
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""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)
if unload_ok:
hass.data.pop(DOMAIN, None)
return unload_ok
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -0,0 +1,87 @@
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
DOMAIN = "bahmcloud_store"
_LOGGER = logging.getLogger(__name__)
CONF_GITHUB_TOKEN = "github_token"
class BCSConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Config flow for Bahmcloud Store.
Design goals:
- GUI setup only (no YAML config)
- store index URL is fixed (not configurable)
- optional GitHub token to raise API rate limits
"""
VERSION = 1
async def async_step_user(self, user_input: dict[str, Any] | None = None):
"""Handle the initial step."""
# Single instance only
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
if user_input is not None:
token = (user_input.get(CONF_GITHUB_TOKEN) or "").strip() or 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)
schema = vol.Schema(
{
vol.Optional(CONF_GITHUB_TOKEN, default=""): str,
}
)
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
def async_get_options_flow(config_entry: config_entries.ConfigEntry):
return BCSOptionsFlowHandler(config_entry)
class BCSOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle options for Bahmcloud Store."""
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
self.config_entry = config_entry
async def async_step_init(self, user_input: dict[str, Any] | None = None):
if user_input is not None:
token = (user_input.get(CONF_GITHUB_TOKEN) or "").strip() or None
opts: dict[str, Any] = dict(self.config_entry.options)
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()
schema = vol.Schema({vol.Optional(CONF_GITHUB_TOKEN, default=cur): str})
return self.async_show_form(
step_id="init",
data_schema=schema,
description_placeholders={
"rate_limit": "Optional. Adds GitHub authorization to avoid rate limits.",
},
)

View File

@@ -34,7 +34,17 @@ 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):
@@ -48,6 +58,7 @@ class BCSInstallError(BCSError):
@dataclass
class BCSConfig:
store_url: str
github_token: str | None = None
@dataclass
@@ -102,6 +113,17 @@ class BCSCore:
# 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
@@ -122,6 +144,31 @@ class BCSCore:
# 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:
@@ -234,7 +281,16 @@ class BCSCore:
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 skipped (no changes detected)")
_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
@@ -257,9 +313,23 @@ class BCSCore:
for r in merged.values():
r.provider = detect_provider(r.url)
# 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(
@@ -298,23 +368,340 @@ class BCSCore:
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
@@ -373,7 +760,11 @@ class BCSCore:
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)
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
@@ -408,21 +799,71 @@ class BCSCore:
if not r:
return None
# If we already have a latest_version (or provider_description), consider it enriched.
if r.latest_version or r.provider_description or r.meta_source:
# 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) -> list[dict[str, Any]]:
async def list_repo_versions(self, repo_id: str, *, limit: int = 20) -> list[dict[str, str]]:
repo = self.get_repo(repo_id)
if not repo:
return []
return await fetch_repo_versions(self.hass, repo.url)
# Prefer cached version lists to avoid hammering provider APIs (notably GitHub unauthenticated
# rate limits). We refresh on-demand when the user opens the selector.
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())
if isinstance(cached, dict) and cached.get("versions") and (now - cached_ts) < VERSIONS_CACHE_TTL_SECONDS:
return list(cached.get("versions") or [])
try:
versions = await fetch_repo_versions(
self.hass,
repo.url,
provider=repo.provider,
default_branch=repo.default_branch,
github_token=self.config.github_token,
limit=limit,
)
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)
@@ -602,23 +1043,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,7 +1,7 @@
{
"domain": "bahmcloud_store",
"name": "Bahmcloud Store",
"version": "0.6.5",
"version": "0.6.9",
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
"platforms": ["update"],
"requirements": [],

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;
@@ -561,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;
@@ -859,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);
@@ -913,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");
@@ -939,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("")}
@@ -972,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) => {
@@ -998,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

@@ -16,6 +16,17 @@ _LOGGER = logging.getLogger(__name__)
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
class RepoInfo:
owner: str | None = None
@@ -189,9 +200,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 +230,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 +331,7 @@ async def _gitlab_latest_version(
return None, None
async def fetch_repo_info(hass: HomeAssistant, repo_url: str) -> RepoInfo:
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 +352,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 +371,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 +529,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.
@@ -560,7 +576,7 @@ async def fetch_repo_versions(
try:
if prov == "github":
# Releases
gh_headers = {"Accept": "application/vnd.github+json", "User-Agent": UA}
gh_headers = _github_headers(github_token)
data, _ = await _safe_json(
session,
f"https://api.github.com/repos/{owner}/{repo}/releases?per_page={int(limit)}",

View File

@@ -37,6 +37,8 @@ class BCSStorage:
- 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:
@@ -57,8 +59,68 @@ class BCSStorage:
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.

View File

@@ -1,4 +1,29 @@
{
"config": {
"abort": {
"single_instance_allowed": "Bahmcloud Store is already configured."
},
"step": {
"user": {
"title": "Set up 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.",
"data": {
"github_token": "GitHub token (optional)"
}
}
}
},
"options": {
"step": {
"init": {
"title": "Bahmcloud Store options",
"description": "Optional: Configure a GitHub token to increase API rate limits.",
"data": {
"github_token": "GitHub token (optional)"
}
}
}
},
"issues": {
"restart_required": {
"title": "Restart required",

View File

@@ -5,6 +5,7 @@ from dataclasses import dataclass
from typing import Any
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -139,13 +140,8 @@ def _sync_entities(core: BCSCore, existing: dict[str, BCSRepoUpdateEntity], asyn
ent.async_write_ha_state()
async def async_setup_platform(
hass: HomeAssistant,
config,
async_add_entities: AddEntitiesCallback,
discovery_info=None,
):
"""Set up BCS update entities."""
async def _async_setup(hass: HomeAssistant, async_add_entities: AddEntitiesCallback) -> None:
"""Common update entity setup for both config entries and legacy YAML."""
core: BCSCore | None = hass.data.get(DOMAIN)
if not core:
_LOGGER.debug("BCS core not available, skipping update platform setup")
@@ -159,4 +155,23 @@ 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: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up update entities from a config entry."""
await _async_setup(hass, async_add_entities)
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)