16 Commits
0.6.9 ... 0.7.2

Author SHA1 Message Date
a8ff892993 add 0.7.2 2026-01-20 08:15:43 +00:00
90223e3fc4 0.7.2 2026-01-20 08:15:14 +00:00
0f5504b67d 0.7.2 2026-01-20 08:14:58 +00:00
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
9 changed files with 280 additions and 151 deletions

View File

@@ -11,6 +11,27 @@ Sections:
--- ---
## 0.7.2 2026-01-20
### Fixed
- When Bahmcloud Store is installed via an external installer (files copied into /config/custom_components), it now reconciles itself as "installed" in BCS storage so update checks work immediately.
## 0.7.1 2026-01-20
### Fixed
- GitHub version provider now reliably fetches the latest 20 releases/tags using authenticated API requests.
- Repositories that were previously fetched in a degraded state (only `latest` and `branch`) are now automatically refreshed on repository view.
- Cached version lists with incomplete data are no longer reused and are re-fetched from the provider.
## [0.7.0] - 2026-01-20
### Added
- Options dialog (gear icon) for the Bahmcloud Store integration.
- Optional GitHub token can now be set, changed or removed via the Home Assistant UI.
### Fixed
- Fixed missing options flow when clicking the integration settings button.
## [0.6.9] 2026-01-19 ## [0.6.9] 2026-01-19
### Added ### Added
- New Home Assistant **GUI setup** (Config Flow) no YAML configuration required. - New Home Assistant **GUI setup** (Config Flow) no YAML configuration required.

View File

@@ -6,48 +6,53 @@ from datetime import timedelta
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.components.panel_custom import async_register_panel from homeassistant.components.panel_custom import async_register_panel
from homeassistant.helpers.event import async_track_time_interval, async_call_later
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.helpers.event import async_call_later, async_track_time_interval
from .core import BCSCore, BCSConfig, BCSError from .const import CONF_GITHUB_TOKEN, DEFAULT_STORE_URL, DOMAIN
from .config_flow import CONF_GITHUB_TOKEN from .core import BCSError, BCSConfig, BCSCore
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DOMAIN = "bahmcloud_store"
# Fixed store index URL (not configurable by users)
DEFAULT_STORE_URL = "https://git.bahmcloud.de/bahmcloud/ha_store/raw/branch/main/store.yaml"
PLATFORMS: list[str] = ["update"] PLATFORMS: list[str] = ["update"]
DATA_ENTRY = f"{DOMAIN}_entry_runtime"
async def async_setup(hass: HomeAssistant, config: dict) -> bool: async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Set up Bahmcloud Store. """Set up Bahmcloud Store.
YAML configuration is intentionally not supported. We intentionally do NOT support YAML configuration.
Setup must happen via the UI (Config Flow). This method is kept so we can log a helpful message if someone tries.
""" """
if DOMAIN in (config or {}): if DOMAIN in (config or {}):
_LOGGER.warning( _LOGGER.warning(
"BCS does not support configuration.yaml setup. " "BCS YAML configuration is no longer supported. "
"Please remove 'bahmcloud_store:' from configuration.yaml and set up the integration via Settings -> Devices & Services." "Please remove 'bahmcloud_store:' from configuration.yaml and set up the integration via the UI."
) )
return True return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Bahmcloud Store from a config entry.""" """Set up Bahmcloud Store from a config entry (UI setup)."""
token = (entry.options.get(CONF_GITHUB_TOKEN) or "").strip() or None # Only one instance.
hass.data.setdefault(DOMAIN, {})
core = BCSCore(hass, BCSConfig(store_url=DEFAULT_STORE_URL, github_token=token)) github_token = (entry.options.get(CONF_GITHUB_TOKEN) or "").strip() or None
hass.data[DOMAIN] = core
core = BCSCore(
hass,
BCSConfig(
store_url=DEFAULT_STORE_URL,
github_token=github_token,
),
)
hass.data[DOMAIN][entry.entry_id] = core
# Keep a convenient shortcut for platforms that previously used hass.data[DOMAIN] directly.
hass.data[DOMAIN]["_core"] = core
await core.async_initialize() await core.async_initialize()
# Register HTTP views + static assets # HTTP views + panel (registered once per entry; we only allow one entry).
from .views import ( from .views import (
StaticAssetsView, StaticAssetsView,
BCSApiView, BCSApiView,
@@ -78,7 +83,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.http.register_view(BCSRestoreView(core)) hass.http.register_view(BCSRestoreView(core))
hass.http.register_view(BCSRestartView(core)) hass.http.register_view(BCSRestartView(core))
# Sidebar panel
await async_register_panel( await async_register_panel(
hass, hass,
frontend_url_path="bahmcloud-store", frontend_url_path="bahmcloud-store",
@@ -91,16 +95,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
config={}, config={},
) )
# Forward platform setup (Update entities)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
async def _do_startup_refresh(_now=None) -> None: async def _do_startup_refresh(_now=None) -> None:
try: try:
await core.full_refresh(source="startup") await core.full_refresh(source="startup")
except BCSError as e: except BCSError as e:
_LOGGER.error("Initial refresh failed: %s", e) _LOGGER.error("Initial refresh failed: %s", e)
# Do not block HA startup: schedule refresh after HA started. # Do not block startup; refresh after HA is up.
def _on_ha_started(_event) -> None: def _on_ha_started(_event) -> None:
async_call_later(hass, 30, _do_startup_refresh) async_call_later(hass, 30, _do_startup_refresh)
@@ -115,30 +116,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.exception("Unexpected error during periodic refresh: %s", e) _LOGGER.exception("Unexpected error during periodic refresh: %s", e)
interval_seconds = int(getattr(core, "refresh_seconds", 300) or 300) interval_seconds = int(getattr(core, "refresh_seconds", 300) or 300)
unsub = async_track_time_interval(hass, periodic, timedelta(seconds=interval_seconds)) async_track_time_interval(hass, periodic, timedelta(seconds=interval_seconds))
# Store unload callbacks safely await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
runtimes = hass.data.setdefault(DATA_ENTRY, {})
runtimes[entry.entry_id] = {"unsub_interval": unsub}
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
try:
runtimes = hass.data.get(DATA_ENTRY, {}) or {}
rt = runtimes.pop(entry.entry_id, {}) if isinstance(runtimes, dict) else {}
unsub = rt.get("unsub_interval")
if callable(unsub):
unsub()
except Exception:
pass
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok: if unload_ok:
hass.data.pop(DOMAIN, None) try:
hass.data.get(DOMAIN, {}).pop(entry.entry_id, None)
except Exception:
pass
return unload_ok return unload_ok
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -1,87 +1,71 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.core import callback
DOMAIN = "bahmcloud_store" from .const import CONF_GITHUB_TOKEN, DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
CONF_GITHUB_TOKEN = "github_token"
def _schema(default_token: str | None = None) -> vol.Schema:
default_token = (default_token or "").strip()
return vol.Schema({vol.Optional(CONF_GITHUB_TOKEN, default=default_token): str})
class BCSConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class BahmcloudStoreConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Config flow for Bahmcloud Store. """Config flow for Bahmcloud Store.
Design goals: The store index URL is fixed and not user-configurable.
- GUI setup only (no YAML config) The only optional setting is a GitHub token to increase API rate limits.
- store index URL is fixed (not configurable)
- optional GitHub token to raise API rate limits
""" """
VERSION = 1 VERSION = 1
async def async_step_user(self, user_input: dict[str, Any] | None = None): async def async_step_user(self, user_input: dict | None = None):
"""Handle the initial step.""" # Allow only one instance.
# Single instance only await self.async_set_unique_id(DOMAIN)
if self._async_current_entries(): self._abort_if_unique_id_configured()
return self.async_abort(reason="single_instance_allowed")
if user_input is not None: if user_input is None:
token = (user_input.get(CONF_GITHUB_TOKEN) or "").strip() or None return self.async_show_form(step_id="user", data_schema=_schema(None))
data: dict[str, Any] = {}
options: dict[str, Any] = {}
if token:
options[CONF_GITHUB_TOKEN] = token
return self.async_create_entry(title="Bahmcloud Store", data=data, options=options) token = str(user_input.get(CONF_GITHUB_TOKEN, "")).strip() or None
schema = vol.Schema( return self.async_create_entry(
{ title="Bahmcloud Store",
vol.Optional(CONF_GITHUB_TOKEN, default=""): str, data={},
} options={CONF_GITHUB_TOKEN: token} if token else {},
)
return self.async_show_form(
step_id="user",
data_schema=schema,
description_placeholders={
"rate_limit": "GitHub API is rate-limited without a token (recommended for large indexes / HACS).",
},
) )
@staticmethod @staticmethod
@callback
def async_get_options_flow(config_entry: config_entries.ConfigEntry): def async_get_options_flow(config_entry: config_entries.ConfigEntry):
return BCSOptionsFlowHandler(config_entry) return BahmcloudStoreOptionsFlowHandler(config_entry)
class BCSOptionsFlowHandler(config_entries.OptionsFlow): class BahmcloudStoreOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle options for Bahmcloud Store.""" """Options flow to manage optional GitHub token."""
def __init__(self, config_entry: config_entries.ConfigEntry) -> None: def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
self.config_entry = config_entry self._config_entry = config_entry
async def async_step_init(self, user_input: dict[str, Any] | None = None): async def async_step_init(self, user_input: dict | None = None):
if user_input is not None: if user_input is None:
token = (user_input.get(CONF_GITHUB_TOKEN) or "").strip() or None current = self._config_entry.options.get(CONF_GITHUB_TOKEN) or ""
opts: dict[str, Any] = dict(self.config_entry.options) return self.async_show_form(step_id="init", data_schema=_schema(str(current)))
token = str(user_input.get(CONF_GITHUB_TOKEN, "")).strip() or None
options = dict(self._config_entry.options)
# Allow clearing the token.
if token: if token:
opts[CONF_GITHUB_TOKEN] = token options[CONF_GITHUB_TOKEN] = token
else: else:
opts.pop(CONF_GITHUB_TOKEN, None) options.pop(CONF_GITHUB_TOKEN, None)
return self.async_create_entry(title="", data=opts)
cur = (self.config_entry.options.get(CONF_GITHUB_TOKEN) or "").strip() return self.async_create_entry(title="", data=options)
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

@@ -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

@@ -181,6 +181,108 @@ class BCSCore:
return await self.hass.async_add_executor_job(_read) return await self.hass.async_add_executor_job(_read)
async def _read_manifest_info_async(self) -> dict[str, str]:
"""Read manifest.json fields that help identify this integration."""
def _read() -> dict[str, str]:
try:
manifest_path = Path(__file__).resolve().parent / "manifest.json"
data = json.loads(manifest_path.read_text(encoding="utf-8"))
out: dict[str, str] = {}
for k in ("version", "documentation", "name", "domain"):
v = data.get(k)
if v:
out[str(k)] = str(v)
return out
except Exception:
return {}
return await self.hass.async_add_executor_job(_read)
def _normalize_repo_base(self, url: str) -> str:
"""Normalize repository URLs to a stable base for matching.
Example:
https://git.example.tld/org/repo/raw/branch/main/store.yaml
becomes:
https://git.example.tld/org/repo
"""
try:
p = urlsplit(str(url or "").strip())
parts = [x for x in (p.path or "").split("/") if x]
base_path = "/" + "/".join(parts[:2]) if len(parts) >= 2 else (p.path or "")
return urlunsplit((p.scheme, p.netloc, base_path.rstrip("/"), "", "")).lower()
except Exception:
return str(url or "").strip().lower()
async def _ensure_self_marked_installed(self, repos: dict[str, RepoItem]) -> None:
"""Ensure BCS is treated as installed when deployed via external installer.
When users install BCS via an installer that places files into
/config/custom_components, our internal storage has no installed entry.
This breaks update detection for the BCS repo entry in the Store.
"""
try:
# Already tracked as installed?
items = await self.storage.list_installed_repos()
for it in items:
if DOMAIN in [str(d) for d in (it.domains or [])]:
return
# Files must exist on disk.
cc_root = Path(self.hass.config.path("custom_components"))
manifest_path = cc_root / DOMAIN / "manifest.json"
if not manifest_path.exists():
return
info = await self._read_manifest_info_async()
doc = (info.get("documentation") or "").strip()
name = (info.get("name") or "").strip()
ver = (info.get("version") or self.version or "unknown").strip()
doc_base = self._normalize_repo_base(doc) if doc else ""
# Identify the matching repo entry in our current repo list.
chosen: RepoItem | None = None
if doc_base:
for r in repos.values():
if self._normalize_repo_base(r.url) == doc_base:
chosen = r
break
if not chosen and name:
for r in repos.values():
if (r.name or "").strip().lower() == name.lower():
chosen = r
break
if not chosen:
for r in repos.values():
if "bahmcloud_store" in (r.url or "").lower():
chosen = r
break
if not chosen:
_LOGGER.debug("BCS self-install reconcile: could not match repo entry")
return
await self.storage.set_installed_repo(
repo_id=chosen.id,
url=chosen.url,
domains=[DOMAIN],
installed_version=ver if ver != "unknown" else None,
installed_manifest_version=ver if ver != "unknown" else None,
ref=ver if ver != "unknown" else None,
)
_LOGGER.info(
"BCS self-install reconcile: marked as installed (repo_id=%s version=%s)",
chosen.id,
ver,
)
except Exception:
_LOGGER.debug("BCS self-install reconcile failed", exc_info=True)
def add_listener(self, cb) -> None: def add_listener(self, cb) -> None:
self._listeners.append(cb) self._listeners.append(cb)
@@ -324,6 +426,12 @@ class BCSCore:
# Apply persisted per-repo enrichment cache (instant UI after restart). # Apply persisted per-repo enrichment cache (instant UI after restart).
self._apply_repo_cache(merged) self._apply_repo_cache(merged)
# If BCS itself was installed via an external installer (i.e. files exist on disk
# but our storage has no installed entry yet), we still want update checks to work.
# Reconcile this once we have the current repo list.
await self._ensure_self_marked_installed(merged)
await self._refresh_installed_cache()
await self._enrich_installed_only(merged) await self._enrich_installed_only(merged)
self.repos = merged self.repos = merged
@@ -697,11 +805,7 @@ class BCSCore:
async def process_one(r: RepoItem) -> None: async def process_one(r: RepoItem) -> None:
async with sem: async with sem:
info: RepoInfo = await fetch_repo_info( info: RepoInfo = await fetch_repo_info(self.hass, r.url, github_token=self.config.github_token)
self.hass,
r.url,
github_token=self.config.github_token,
)
r.provider = info.provider or r.provider r.provider = info.provider or r.provider
r.owner = info.owner or r.owner r.owner = info.owner or r.owner
@@ -760,11 +864,7 @@ class BCSCore:
async def _enrich_one_repo(self, r: RepoItem) -> None: async def _enrich_one_repo(self, r: RepoItem) -> None:
"""Fetch provider info + metadata for a single repo item.""" """Fetch provider info + metadata for a single repo item."""
info: RepoInfo = await fetch_repo_info( info: RepoInfo = await fetch_repo_info(self.hass, r.url, github_token=self.config.github_token)
self.hass,
r.url,
github_token=self.config.github_token,
)
r.provider = info.provider or r.provider r.provider = info.provider or r.provider
r.owner = info.owner or r.owner r.owner = info.owner or r.owner
@@ -816,13 +916,21 @@ class BCSCore:
_LOGGER.debug("BCS ensure_repo_details failed for %s", repo_id, exc_info=True) _LOGGER.debug("BCS ensure_repo_details failed for %s", repo_id, exc_info=True)
return r return r
async def list_repo_versions(self, repo_id: str, *, limit: int = 20) -> list[dict[str, str]]: async def list_repo_versions(
self,
repo_id: str,
*,
limit: int = 20,
force_refresh: bool = False,
) -> list[dict[str, Any]]:
repo = self.get_repo(repo_id) repo = self.get_repo(repo_id)
if not repo: if not repo:
return [] return []
# Prefer cached version lists to avoid hammering provider APIs (notably GitHub unauthenticated # Prefer cached version lists to avoid hammering provider APIs (notably GitHub unauthenticated
# rate limits). We refresh on-demand when the user opens the selector. # rate limits). However, if the cached list is clearly a degraded fallback (e.g. only
# "Latest" + "Branch"), we treat it as stale and retry immediately when the user requests
# versions again.
cached = None cached = None
cached_ts = 0 cached_ts = 0
async with self._repo_cache_lock: async with self._repo_cache_lock:
@@ -831,8 +939,17 @@ class BCSCore:
cached_ts = int(cached.get("versions_ts", 0) or 0) cached_ts = int(cached.get("versions_ts", 0) or 0)
now = int(time.time()) now = int(time.time())
if isinstance(cached, dict) and cached.get("versions") and (now - cached_ts) < VERSIONS_CACHE_TTL_SECONDS: cached_versions = list(cached.get("versions") or []) if isinstance(cached, dict) else []
return list(cached.get("versions") or []) cache_fresh = (now - cached_ts) < VERSIONS_CACHE_TTL_SECONDS
# Cache hit if it's fresh and not degraded, unless the caller explicitly wants a refresh.
if (
not force_refresh
and cached_versions
and cache_fresh
and len(cached_versions) > 2
):
return cached_versions
try: try:
versions = await fetch_repo_versions( versions = await fetch_repo_versions(
@@ -840,8 +957,8 @@ class BCSCore:
repo.url, repo.url,
provider=repo.provider, provider=repo.provider,
default_branch=repo.default_branch, default_branch=repo.default_branch,
github_token=self.config.github_token,
limit=limit, limit=limit,
github_token=self.config.github_token,
) )
except Exception: except Exception:
versions = [] versions = []

View File

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

View File

@@ -16,17 +16,6 @@ _LOGGER = logging.getLogger(__name__)
UA = "BahmcloudStore (Home Assistant)" UA = "BahmcloudStore (Home Assistant)"
def _github_headers(github_token: str | None = None) -> dict[str, str]:
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": UA,
}
token = (github_token or "").strip()
if token:
headers["Authorization"] = f"Bearer {token}"
return headers
@dataclass @dataclass
class RepoInfo: class RepoInfo:
owner: str | None = None owner: str | None = None
@@ -331,6 +320,15 @@ async def _gitlab_latest_version(
return None, None return None, None
def _github_headers(github_token: str | None = None) -> dict:
headers = {"Accept": "application/vnd.github+json", "User-Agent": UA}
token = (github_token or "").strip()
if token:
# PAT or fine-grained token
headers["Authorization"] = f"Bearer {token}"
return headers
async def fetch_repo_info(hass: HomeAssistant, repo_url: str, *, github_token: str | None = None) -> RepoInfo: async def fetch_repo_info(hass: HomeAssistant, repo_url: str, *, github_token: str | None = None) -> RepoInfo:
provider = detect_provider(repo_url) provider = detect_provider(repo_url)
owner, repo = _split_owner_repo(repo_url) owner, repo = _split_owner_repo(repo_url)
@@ -539,7 +537,7 @@ async def fetch_repo_versions(
- source: release|tag|branch - source: release|tag|branch
Notes: Notes:
- Uses public endpoints (no tokens) for public repositories. - Uses provider APIs; for GitHub we include the configured token (if any) to avoid unauthenticated rate limits.
- We prefer releases first (if available), then tags. - We prefer releases first (if available), then tags.
""" """
@@ -557,6 +555,8 @@ async def fetch_repo_versions(
session = async_get_clientsession(hass) session = async_get_clientsession(hass)
headers = {"User-Agent": UA} headers = {"User-Agent": UA}
if prov == "github":
headers = _github_headers(github_token)
out: list[dict[str, str]] = [] out: list[dict[str, str]] = []
seen: set[str] = set() seen: set[str] = set()
@@ -575,11 +575,13 @@ async def fetch_repo_versions(
try: try:
if prov == "github": if prov == "github":
# Releases # Releases (prefer these over tags)
# Use the configured GitHub token (if any) to avoid unauthenticated rate limits.
gh_headers = _github_headers(github_token) gh_headers = _github_headers(github_token)
per_page = max(1, min(int(limit), 100))
data, _ = await _safe_json( data, _ = await _safe_json(
session, session,
f"https://api.github.com/repos/{owner}/{repo}/releases?per_page={int(limit)}", f"https://api.github.com/repos/{owner}/{repo}/releases?per_page={per_page}",
headers=gh_headers, headers=gh_headers,
) )
if isinstance(data, list): if isinstance(data, list):
@@ -597,7 +599,7 @@ async def fetch_repo_versions(
# Tags # Tags
data, _ = await _safe_json( data, _ = await _safe_json(
session, session,
f"https://api.github.com/repos/{owner}/{repo}/tags?per_page={int(limit)}", f"https://api.github.com/repos/{owner}/{repo}/tags?per_page={per_page}",
headers=gh_headers, headers=gh_headers,
) )
if isinstance(data, list): if isinstance(data, list):

View File

@@ -1,12 +1,12 @@
{ {
"config": { "config": {
"abort": { "abort": {
"single_instance_allowed": "Bahmcloud Store is already configured." "single_instance_allowed": "Only one Bahmcloud Store instance can be configured."
}, },
"step": { "step": {
"user": { "user": {
"title": "Set up Bahmcloud Store", "title": "Bahmcloud Store",
"description": "The store index is fixed to the official Bahmcloud Store.\n\nOptional: Add a GitHub token to increase API rate limits for GitHub/HACS repositories.", "description": "Bahmcloud Store uses a fixed official store index. You can optionally add a GitHub token to increase API rate limits.",
"data": { "data": {
"github_token": "GitHub token (optional)" "github_token": "GitHub token (optional)"
} }
@@ -16,8 +16,8 @@
"options": { "options": {
"step": { "step": {
"init": { "init": {
"title": "Bahmcloud Store options", "title": "Bahmcloud Store Options",
"description": "Optional: Configure a GitHub token to increase API rate limits.", "description": "Optionally set or clear your GitHub token to reduce rate limiting.",
"data": { "data": {
"github_token": "GitHub token (optional)" "github_token": "GitHub token (optional)"
} }

View File

@@ -5,17 +5,26 @@ from dataclasses import dataclass
from typing import Any from typing import Any
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity import EntityCategory
from .core import DOMAIN, SIGNAL_UPDATED, BCSCore from .const import DOMAIN
from .core import SIGNAL_UPDATED, BCSCore
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
def _get_core(hass: HomeAssistant) -> BCSCore | None:
data = hass.data.get(DOMAIN)
if isinstance(data, dict):
c = data.get("_core")
return c if isinstance(c, BCSCore) else None
# Backwards compatibility (older versions used hass.data[DOMAIN] = core)
return data if isinstance(data, BCSCore) else None
def _pretty_repo_name(core: BCSCore, repo_id: str) -> str: def _pretty_repo_name(core: BCSCore, repo_id: str) -> str:
"""Return a human-friendly name for a repo update entity.""" """Return a human-friendly name for a repo update entity."""
try: try:
@@ -140,9 +149,14 @@ def _sync_entities(core: BCSCore, existing: dict[str, BCSRepoUpdateEntity], asyn
ent.async_write_ha_state() ent.async_write_ha_state()
async def _async_setup(hass: HomeAssistant, async_add_entities: AddEntitiesCallback) -> None: async def async_setup_platform(
"""Common update entity setup for both config entries and legacy YAML.""" hass: HomeAssistant,
core: BCSCore | None = hass.data.get(DOMAIN) config,
async_add_entities: AddEntitiesCallback,
discovery_info=None,
):
"""Set up BCS update entities."""
core: BCSCore | None = _get_core(hass)
if not core: if not core:
_LOGGER.debug("BCS core not available, skipping update platform setup") _LOGGER.debug("BCS core not available, skipping update platform setup")
return return
@@ -160,18 +174,8 @@ async def _async_setup(hass: HomeAssistant, async_add_entities: AddEntitiesCallb
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: ConfigEntry, entry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up update entities from a config entry.""" """Set up BCS update entities from a config entry."""
await _async_setup(hass, async_add_entities) await async_setup_platform(hass, {}, async_add_entities, None)
async def async_setup_platform(
hass: HomeAssistant,
config,
async_add_entities: AddEntitiesCallback,
discovery_info=None,
):
"""Legacy YAML setup (not supported, kept for safety)."""
await _async_setup(hass, async_add_entities)