20 Commits
0.5.0 ... 0.5.2

Author SHA1 Message Date
01576153d8 Add 0.5.2 2026-01-16 17:31:49 +00:00
30484a08c1 V0. 5.2 2026-01-16 17:30:51 +00:00
faf122aa1c Fic install 2026-01-16 17:27:38 +00:00
1e86df49e9 Fux insta 2026-01-16 17:27:02 +00:00
df631eec9e Fix install 2026-01-16 17:26:22 +00:00
07240d1268 Add 2026-01-16 16:50:25 +00:00
50587ffbbd 12 2026-01-16 16:17:43 +00:00
d6347e7e59 . 2026-01-16 16:06:52 +00:00
870e77ec13 .. 2026-01-15 20:40:04 +00:00
38fb9fb073 .. 2026-01-15 20:32:21 +00:00
c20bd4dd07 .. 2026-01-15 20:25:34 +00:00
296c816633 . 2026-01-15 20:12:30 +00:00
18a2b5529c . 2026-01-15 19:53:44 +00:00
246fab7e1e . 2026-01-15 19:53:07 +00:00
ce5802721f . 2026-01-15 19:52:05 +00:00
2f46966fe2 . 2026-01-15 19:51:26 +00:00
132f9e27c1 revert 6488b434d8
revert custom_components/bahmcloud_store/core.py aktualisiert
2026-01-15 18:02:30 +00:00
618511be73 revert bffc594da5
revert custom_components/bahmcloud_store/views.py aktualisiert
2026-01-15 18:02:11 +00:00
6488b434d8 custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 18:00:56 +00:00
bffc594da5 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 18:00:32 +00:00
10 changed files with 1519 additions and 1023 deletions

View File

@@ -11,6 +11,19 @@ Sections:
--- ---
## [0.5.2] - 2026-01-16
### Added
- Install and update backend endpoints (`POST /api/bcs/install`, `POST /api/bcs/update`) to install repositories into `/config/custom_components`.
- Installed version tracking based on the actually installed ref (tag/release/branch), stored persistently to support repositories with outdated/`0.0.0` manifest versions.
- API fields `installed_version` (installed ref) and `installed_manifest_version` (informational) to improve transparency in the UI.
### Changed
- Update availability is now evaluated using the stored installed ref (instead of `manifest.json` version), preventing false-positive updates when repositories do not maintain manifest versions.
### Fixed
- Repositories with `manifest.json` version `0.0.0` (or stale versions) no longer appear as constantly requiring updates after installing the latest release/tag.
## [0.5.0] - 2026-01-15 ## [0.5.0] - 2026-01-15
### Added ### Added

19
bcs.yaml Normal file
View File

@@ -0,0 +1,19 @@
name: Bahmcloud Store
description: >
Provider-neutral custom integration store for Home Assistant.
Supports GitHub, GitLab, Gitea and Bahmcloud repositories with
a central index, UI panel and API, similar to HACS but independent.
category: Store
author: Bahmcloud
maintainer: Bahmcloud
domains:
- bahmcloud_store
min_ha_version: "2024.1.0"
homepage: https://git.bahmcloud.de/bahmcloud/bahmcloud_store
issues: https://git.bahmcloud.de/bahmcloud/bahmcloud_store/issues
source: https://git.bahmcloud.de/bahmcloud/bahmcloud_store

View File

@@ -24,29 +24,38 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
core = BCSCore(hass, BCSConfig(store_url=store_url)) core = BCSCore(hass, BCSConfig(store_url=store_url))
hass.data[DOMAIN] = core hass.data[DOMAIN] = core
# Avoid blocking IO during setup
await core.async_initialize() await core.async_initialize()
# Register HTTP views and panel from .views import (
from .views import StaticAssetsView, BCSApiView, BCSReadmeView, BCSCustomRepoView StaticAssetsView,
BCSApiView,
BCSReadmeView,
BCSCustomRepoView,
BCSInstallView,
BCSUpdateView,
BCSRestartView,
)
hass.http.register_view(StaticAssetsView()) hass.http.register_view(StaticAssetsView())
hass.http.register_view(BCSApiView(core)) hass.http.register_view(BCSApiView(core))
hass.http.register_view(BCSReadmeView(core)) hass.http.register_view(BCSReadmeView(core))
hass.http.register_view(BCSCustomRepoView(core)) hass.http.register_view(BCSCustomRepoView(core))
hass.http.register_view(BCSInstallView(core))
hass.http.register_view(BCSUpdateView(core))
hass.http.register_view(BCSRestartView(core))
await async_register_panel( await async_register_panel(
hass, hass,
frontend_url_path="bahmcloud-store", frontend_url_path="bahmcloud-store",
webcomponent_name="bahmcloud-store-panel", webcomponent_name="bahmcloud-store-panel",
module_url="/api/bahmcloud_store_static/panel.js?v=99", # IMPORTANT: bump v to avoid caching old JS
module_url="/api/bahmcloud_store_static/panel.js?v=101",
sidebar_title="Bahmcloud Store", sidebar_title="Bahmcloud Store",
sidebar_icon="mdi:store", sidebar_icon="mdi:store",
require_admin=True, require_admin=True,
config={}, config={},
) )
# Initial refresh
try: try:
await core.full_refresh(source="startup") await core.full_refresh(source="startup")
except BCSError as e: except BCSError as e:
@@ -57,7 +66,7 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
await core.full_refresh(source="timer") await core.full_refresh(source="timer")
except BCSError as e: except BCSError as e:
_LOGGER.warning("Periodic refresh failed: %s", e) _LOGGER.warning("Periodic refresh failed: %s", e)
except Exception as e: except Exception as e: # pylint: disable=broad-exception-caught
_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)

View File

@@ -5,13 +5,17 @@ import hashlib
import json import json
import logging import logging
import time import time
import shutil
import tempfile
import zipfile
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit, urlparse
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.components import persistent_notification
from homeassistant.util import yaml as ha_yaml from homeassistant.util import yaml as ha_yaml
from .storage import BCSStorage, CustomRepo from .storage import BCSStorage, CustomRepo
@@ -27,6 +31,10 @@ class BCSError(Exception):
"""BCS core error.""" """BCS core error."""
class BCSInstallError(BCSError):
"""BCS installation/update error."""
@dataclass @dataclass
class BCSConfig: class BCSConfig:
store_url: str store_url: str
@@ -75,9 +83,13 @@ class BCSCore:
self.last_index_hash: str | None = None self.last_index_hash: str | None = None
self.last_index_loaded_at: float | None = None self.last_index_loaded_at: float | None = None
self._install_lock = asyncio.Lock()
self._installed_cache: dict[str, Any] = {}
async def async_initialize(self) -> None: async def async_initialize(self) -> None:
"""Async initialization that avoids blocking file IO.""" """Async initialization that avoids blocking file IO."""
self.version = await self._read_manifest_version_async() self.version = await self._read_manifest_version_async()
await self._refresh_installed_cache()
async def _read_manifest_version_async(self) -> str: async def _read_manifest_version_async(self) -> str:
def _read() -> str: def _read() -> str:
@@ -293,7 +305,30 @@ class BCSCore:
def list_repos_public(self) -> list[dict[str, Any]]: def list_repos_public(self) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
installed_map: dict[str, Any] = getattr(self, "_installed_cache", {}) or {}
if not isinstance(installed_map, dict):
installed_map = {}
for r in self.repos.values(): for r in self.repos.values():
inst = installed_map.get(r.id)
installed = bool(inst)
installed_domains: list[str] = []
installed_version: str | None = None
installed_manifest_version: str | None = None
if isinstance(inst, dict):
d = inst.get("domains") or []
if isinstance(d, list):
installed_domains = [str(x) for x in d if str(x).strip()]
# IMPORTANT: this is the ref we installed (tag/release/branch)
v = inst.get("installed_version")
installed_version = str(v) if v is not None else None
mv = inst.get("installed_manifest_version")
installed_manifest_version = str(mv) if mv is not None else None
out.append( out.append(
{ {
"id": r.id, "id": r.id,
@@ -311,6 +346,10 @@ class BCSCore:
"meta_author": r.meta_author, "meta_author": r.meta_author,
"meta_maintainer": r.meta_maintainer, "meta_maintainer": r.meta_maintainer,
"meta_source": r.meta_source, "meta_source": r.meta_source,
"installed": installed,
"installed_version": installed_version,
"installed_manifest_version": installed_manifest_version,
"installed_domains": installed_domains,
} }
) )
return out return out
@@ -326,3 +365,219 @@ class BCSCore:
provider=repo.provider, provider=repo.provider,
default_branch=repo.default_branch, default_branch=repo.default_branch,
) )
def _pick_ref_for_install(self, repo: RepoItem) -> str:
# Prefer latest_version (release/tag/atom-derived), fallback to default branch, then main.
if repo.latest_version and str(repo.latest_version).strip():
return str(repo.latest_version).strip()
if repo.default_branch and str(repo.default_branch).strip():
return str(repo.default_branch).strip()
return "main"
def _build_zip_url(self, repo_url: str, ref: str) -> str:
"""Build a public ZIP download URL (provider-neutral, no tokens).
Supports:
- GitHub: codeload
- GitLab: /-/archive/
- Gitea (incl. Bahmcloud): /archive/<ref>.zip
"""
ref = (ref or "").strip()
if not ref:
raise BCSInstallError("Missing ref for ZIP download")
u = urlparse(repo_url.rstrip("/"))
host = (u.netloc or "").lower()
parts = [p for p in u.path.strip("/").split("/") if p]
if len(parts) < 2:
raise BCSInstallError("Invalid repository URL (missing owner/repo)")
owner = parts[0]
repo = parts[1]
if repo.endswith(".git"):
repo = repo[:-4]
if "github.com" in host:
return f"https://codeload.github.com/{owner}/{repo}/zip/{ref}"
if "gitlab" in host:
base = f"{u.scheme}://{u.netloc}"
path = u.path.strip("/")
if path.endswith(".git"):
path = path[:-4]
return f"{base}/{path}/-/archive/{ref}/{repo}-{ref}.zip"
base = f"{u.scheme}://{u.netloc}"
path = u.path.strip("/")
if path.endswith(".git"):
path = path[:-4]
return f"{base}/{path}/archive/{ref}.zip"
async def _download_zip(self, url: str, dest: Path) -> None:
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(url, timeout=120, headers=headers) as resp:
if resp.status != 200:
raise BCSInstallError(f"zip_url returned {resp.status}")
data = await resp.read()
await self.hass.async_add_executor_job(dest.write_bytes, data)
async def _extract_zip(self, zip_path: Path, extract_dir: Path) -> None:
def _extract() -> None:
with zipfile.ZipFile(zip_path, "r") as zf:
zf.extractall(extract_dir)
await self.hass.async_add_executor_job(_extract)
@staticmethod
def _find_custom_components_root(extract_root: Path) -> Path | None:
direct = extract_root / "custom_components"
if direct.exists() and direct.is_dir():
return direct
for child in extract_root.iterdir():
candidate = child / "custom_components"
if candidate.exists() and candidate.is_dir():
return candidate
return None
async def _copy_domain_dir(self, src_domain_dir: Path, domain: str) -> None:
dest_root = Path(self.hass.config.path("custom_components"))
target = dest_root / domain
tmp_target = dest_root / f".bcs_tmp_{domain}_{int(time.time())}"
def _copy() -> None:
if tmp_target.exists():
shutil.rmtree(tmp_target, ignore_errors=True)
shutil.copytree(src_domain_dir, tmp_target, dirs_exist_ok=True)
if target.exists():
shutil.rmtree(target, ignore_errors=True)
tmp_target.rename(target)
await self.hass.async_add_executor_job(_copy)
async def _read_installed_manifest_version(self, domain: str) -> str | None:
def _read() -> str | None:
try:
p = Path(self.hass.config.path("custom_components", domain, "manifest.json"))
if not p.exists():
return None
data = json.loads(p.read_text(encoding="utf-8"))
v = data.get("version")
return str(v) if v else None
except Exception:
return None
return await self.hass.async_add_executor_job(_read)
async def _refresh_installed_cache(self) -> None:
try:
items = await self.storage.list_installed_repos()
cache: dict[str, Any] = {}
for it in items:
cache[it.repo_id] = {
"domains": it.domains,
"installed_version": it.installed_version, # BCS ref
"installed_manifest_version": it.installed_manifest_version,
"ref": it.ref,
"installed_at": it.installed_at,
}
self._installed_cache = cache
except Exception:
self._installed_cache = {}
async def install_repo(self, repo_id: str) -> dict[str, Any]:
repo = self.get_repo(repo_id)
if not repo:
raise BCSInstallError(f"repo_id not found: {repo_id}")
async with self._install_lock:
ref = self._pick_ref_for_install(repo)
zip_url = self._build_zip_url(repo.url, ref)
_LOGGER.info("BCS install started: repo_id=%s ref=%s zip_url=%s", repo_id, ref, zip_url)
with tempfile.TemporaryDirectory(prefix="bcs_install_") as td:
tmp = Path(td)
zip_path = tmp / "repo.zip"
extract_dir = tmp / "extract"
extract_dir.mkdir(parents=True, exist_ok=True)
await self._download_zip(zip_url, zip_path)
await self._extract_zip(zip_path, extract_dir)
cc_root = self._find_custom_components_root(extract_dir)
if not cc_root:
raise BCSInstallError("custom_components folder not found in repository ZIP")
installed_domains: list[str] = []
for domain_dir in cc_root.iterdir():
if not domain_dir.is_dir():
continue
manifest = domain_dir / "manifest.json"
if not manifest.exists():
continue
domain = domain_dir.name
await self._copy_domain_dir(domain_dir, domain)
installed_domains.append(domain)
if not installed_domains:
raise BCSInstallError("No integrations found under custom_components/ (missing manifest.json)")
# informational only (many repos are wrong here)
installed_manifest_version = await self._read_installed_manifest_version(installed_domains[0])
# IMPORTANT: BCS "installed_version" is the ref we installed (tag/release/branch),
# so update logic won't break when manifest.json is 0.0.0 or outdated.
installed_version = ref
await self.storage.set_installed_repo(
repo_id=repo_id,
url=repo.url,
domains=installed_domains,
installed_version=installed_version,
installed_manifest_version=installed_manifest_version,
ref=ref,
)
await self._refresh_installed_cache()
persistent_notification.async_create(
self.hass,
"Bahmcloud Store installation finished. A Home Assistant restart is required to load the integration.",
title="Bahmcloud Store",
notification_id="bcs_restart_required",
)
_LOGGER.info(
"BCS install complete: repo_id=%s domains=%s installed_ref=%s manifest_version=%s",
repo_id,
installed_domains,
installed_version,
installed_manifest_version,
)
self.signal_updated()
return {
"ok": True,
"repo_id": repo_id,
"domains": installed_domains,
"installed_version": installed_version,
"installed_manifest_version": installed_manifest_version,
"restart_required": True,
}
async def update_repo(self, repo_id: str) -> dict[str, Any]:
_LOGGER.info("BCS update started: repo_id=%s", repo_id)
return await self.install_repo(repo_id)
async def request_restart(self) -> None:
await self.hass.services.async_call("homeassistant", "restart", {}, blocking=False)

View File

@@ -1,7 +1,7 @@
{ {
"domain": "bahmcloud_store", "domain": "bahmcloud_store",
"name": "Bahmcloud Store", "name": "Bahmcloud Store",
"version": "0.5.0", "version": "0.5.2",
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store", "documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
"requirements": [], "requirements": [],
"codeowners": ["@bahmcloud"], "codeowners": ["@bahmcloud"],

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,8 @@ import xml.etree.ElementTree as ET
from dataclasses import dataclass from dataclasses import dataclass
from urllib.parse import quote_plus, urlparse from urllib.parse import quote_plus, urlparse
from packaging.version import InvalidVersion, Version
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -97,6 +99,36 @@ def _extract_meta(html: str, *, prop: str | None = None, name: str | None = None
return None return None
def _semver_key(tag: str) -> Version | None:
t = (tag or "").strip()
if not t:
return None
if t.startswith(("v", "V")):
t = t[1:]
try:
return Version(t)
except InvalidVersion:
return None
def _pick_highest_semver(tags: list[str]) -> str | None:
parsed: list[tuple[Version, str]] = []
for t in tags:
if not isinstance(t, str):
continue
ts = t.strip()
if not ts:
continue
v = _semver_key(ts)
if v is not None:
parsed.append((v, ts))
if not parsed:
return None
parsed.sort(key=lambda x: x[0], reverse=True)
return parsed[0][1]
async def _github_description_html(hass: HomeAssistant, owner: str, repo: str) -> str | None: async def _github_description_html(hass: HomeAssistant, owner: str, repo: str) -> str | None:
session = async_get_clientsession(hass) session = async_get_clientsession(hass)
url = f"https://github.com/{owner}/{repo}" url = f"https://github.com/{owner}/{repo}"
@@ -165,12 +197,22 @@ async def _github_latest_version_api(hass: HomeAssistant, owner: str, repo: str)
if isinstance(data, dict) and data.get("tag_name"): if isinstance(data, dict) and data.get("tag_name"):
return str(data["tag_name"]), "release" return str(data["tag_name"]), "release"
# No releases -> pick highest semver from many tags (instead of per_page=1)
if status == 404: if status == 404:
data, _ = await _safe_json(session, f"https://api.github.com/repos/{owner}/{repo}/tags?per_page=1", headers=headers) data, _ = await _safe_json(session, f"https://api.github.com/repos/{owner}/{repo}/tags?per_page=100", headers=headers)
if isinstance(data, list) and data: tags: list[str] = []
t = data[0] if isinstance(data, list):
for t in data:
if isinstance(t, dict) and t.get("name"): if isinstance(t, dict) and t.get("name"):
return str(t["name"]), "tag" tags.append(str(t["name"]))
best = _pick_highest_semver(tags)
if best:
return best, "tag"
# fallback: keep old behavior (first tag)
if tags:
return tags[0], "tag"
return None, None return None, None
@@ -190,17 +232,33 @@ async def _github_latest_version(hass: HomeAssistant, owner: str, repo: str) ->
async def _gitea_latest_version(hass: HomeAssistant, base: str, owner: str, repo: str) -> tuple[str | None, str | None]: async def _gitea_latest_version(hass: HomeAssistant, base: str, owner: str, repo: str) -> tuple[str | None, str | None]:
session = async_get_clientsession(hass) session = async_get_clientsession(hass)
data, _ = await _safe_json(session, f"{base}/api/v1/repos/{owner}/{repo}/releases?limit=1") # releases: fetch multiple, pick highest semver (instead of limit=1)
if isinstance(data, list) and data: data, _ = await _safe_json(session, f"{base}/api/v1/repos/{owner}/{repo}/releases?limit=50")
r = data[0] rel_tags: list[str] = []
if isinstance(data, list):
for r in data:
if isinstance(r, dict) and r.get("tag_name"): if isinstance(r, dict) and r.get("tag_name"):
return str(r["tag_name"]), "release" rel_tags.append(str(r["tag_name"]))
data, _ = await _safe_json(session, f"{base}/api/v1/repos/{owner}/{repo}/tags?limit=1") best_rel = _pick_highest_semver(rel_tags)
if isinstance(data, list) and data: if best_rel:
t = data[0] return best_rel, "release"
if rel_tags:
return rel_tags[0], "release"
# tags: fetch multiple, pick highest semver (instead of limit=1)
data, _ = await _safe_json(session, f"{base}/api/v1/repos/{owner}/{repo}/tags?limit=50")
tags: list[str] = []
if isinstance(data, list):
for t in data:
if isinstance(t, dict) and t.get("name"): if isinstance(t, dict) and t.get("name"):
return str(t["name"]), "tag" tags.append(str(t["name"]))
best = _pick_highest_semver(tags)
if best:
return best, "tag"
if tags:
return tags[0], "tag"
return None, None return None, None
@@ -213,18 +271,35 @@ async def _gitlab_latest_version(
project = quote_plus(f"{owner}/{repo}") project = quote_plus(f"{owner}/{repo}")
data, _ = await _safe_json(session, f"{base}/api/v4/projects/{project}/releases?per_page=1", headers=headers) # releases: fetch multiple, pick highest semver (instead of per_page=1)
if isinstance(data, list) and data: data, _ = await _safe_json(session, f"{base}/api/v4/projects/{project}/releases?per_page=50", headers=headers)
r = data[0] rel_tags: list[str] = []
if isinstance(data, list):
for r in data:
if isinstance(r, dict) and r.get("tag_name"): if isinstance(r, dict) and r.get("tag_name"):
return str(r["tag_name"]), "release" rel_tags.append(str(r["tag_name"]))
data, _ = await _safe_json(session, f"{base}/api/v4/projects/{project}/repository/tags?per_page=1", headers=headers) best_rel = _pick_highest_semver(rel_tags)
if isinstance(data, list) and data: if best_rel:
t = data[0] return best_rel, "release"
if rel_tags:
return rel_tags[0], "release"
# tags: fetch multiple, pick highest semver (instead of per_page=1)
data, _ = await _safe_json(session, f"{base}/api/v4/projects/{project}/repository/tags?per_page=50", headers=headers)
tags: list[str] = []
if isinstance(data, list):
for t in data:
if isinstance(t, dict) and t.get("name"): if isinstance(t, dict) and t.get("name"):
return str(t["name"]), "tag" tags.append(str(t["name"]))
best = _pick_highest_semver(tags)
if best:
return best, "tag"
if tags:
return tags[0], "tag"
# atom fallback
atom, status = await _safe_text(session, f"{base}/{owner}/{repo}/-/tags?format=atom", headers=headers) atom, status = await _safe_text(session, f"{base}/{owner}/{repo}/-/tags?format=atom", headers=headers)
if status == 200 and atom: if status == 200 and atom:
try: try:

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import time
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
@@ -18,19 +19,40 @@ class CustomRepo:
name: str | None = None name: str | None = None
@dataclass
class InstalledRepo:
repo_id: str
url: str
domains: list[str]
installed_at: int
installed_version: str | None = None # BCS "installed ref" (tag/release/branch)
installed_manifest_version: str | None = None # informational only
ref: str | None = None # kept for backward compatibility / diagnostics
class BCSStorage: class BCSStorage:
"""Persistent storage for manually added repositories.""" """Persistent storage for Bahmcloud Store.
Keys:
- custom_repos: list of manually added repositories
- installed_repos: mapping repo_id -> installed metadata
"""
def __init__(self, hass: HomeAssistant) -> None: def __init__(self, hass: HomeAssistant) -> None:
self.hass = hass self.hass = hass
self._store = Store(hass, _STORAGE_VERSION, _STORAGE_KEY) self._store: Store[dict[str, Any]] = Store(hass, _STORAGE_VERSION, _STORAGE_KEY)
async def _load(self) -> dict[str, Any]: async def _load(self) -> dict[str, Any]:
data = await self._store.async_load() data = await self._store.async_load() or {}
if not data: if not isinstance(data, dict):
return {"custom_repos": []} data = {}
if "custom_repos" not in data:
if "custom_repos" not in data or not isinstance(data.get("custom_repos"), list):
data["custom_repos"] = [] data["custom_repos"] = []
if "installed_repos" not in data or not isinstance(data.get("installed_repos"), dict):
data["installed_repos"] = {}
return data return data
async def _save(self, data: dict[str, Any]) -> None: async def _save(self, data: dict[str, Any]) -> None:
@@ -43,24 +65,20 @@ class BCSStorage:
for r in repos: for r in repos:
if not isinstance(r, dict): if not isinstance(r, dict):
continue continue
rid = str(r.get("id") or "") rid = r.get("id")
url = str(r.get("url") or "") url = r.get("url")
name = r.get("name") if not rid or not url:
if rid and url: continue
out.append(CustomRepo(id=rid, url=url, name=str(name) if name else None)) out.append(CustomRepo(id=str(rid), url=str(url), name=r.get("name")))
return out return out
async def add_custom_repo(self, url: str, name: str | None) -> CustomRepo: async def add_custom_repo(self, url: str, name: str | None) -> CustomRepo:
data = await self._load() data = await self._load()
repos = data.get("custom_repos", []) repos = data.get("custom_repos", [])
# Deduplicate by URL # De-duplicate by URL
for r in repos: for r in repos:
if isinstance(r, dict) and str(r.get("url", "")).strip() == url.strip(): if isinstance(r, dict) and str(r.get("url") or "").strip() == url.strip():
# Update name if provided
if name:
r["name"] = name
await self._save(data)
return CustomRepo(id=str(r["id"]), url=str(r["url"]), name=r.get("name")) return CustomRepo(id=str(r["id"]), url=str(r["url"]), name=r.get("name"))
rid = f"custom:{uuid.uuid4().hex[:10]}" rid = f"custom:{uuid.uuid4().hex[:10]}"
@@ -73,6 +91,94 @@ class BCSStorage:
async def remove_custom_repo(self, repo_id: str) -> None: async def remove_custom_repo(self, repo_id: str) -> None:
data = await self._load() data = await self._load()
repos = data.get("custom_repos", []) repos = data.get("custom_repos", [])
data["custom_repos"] = [r for r in repos if not (isinstance(r, dict) and r.get("id") == repo_id)] data["custom_repos"] = [
r for r in repos if not (isinstance(r, dict) and r.get("id") == repo_id)
]
await self._save(data) await self._save(data)
async def get_installed_repo(self, repo_id: str) -> InstalledRepo | None:
data = await self._load()
installed = data.get("installed_repos", {})
if not isinstance(installed, dict):
return None
entry = installed.get(repo_id)
if not isinstance(entry, dict):
return None
try:
domains = entry.get("domains") or []
if not isinstance(domains, list):
domains = []
domains = [str(d) for d in domains if str(d).strip()]
installed_version = entry.get("installed_version")
ref = entry.get("ref")
# Backward compatibility:
# If installed_version wasn't stored, fall back to ref.
if (not installed_version) and ref:
installed_version = ref
installed_manifest_version = entry.get("installed_manifest_version")
return InstalledRepo(
repo_id=str(entry.get("repo_id") or repo_id),
url=str(entry.get("url") or ""),
domains=domains,
installed_at=int(entry.get("installed_at") or 0),
installed_version=str(installed_version) if installed_version else None,
installed_manifest_version=str(installed_manifest_version) if installed_manifest_version else None,
ref=str(ref) if ref else None,
)
except Exception:
return None
async def list_installed_repos(self) -> list[InstalledRepo]:
data = await self._load()
installed = data.get("installed_repos", {})
out: list[InstalledRepo] = []
if not isinstance(installed, dict):
return out
for rid in list(installed.keys()):
item = await self.get_installed_repo(str(rid))
if item:
out.append(item)
return out
async def set_installed_repo(
self,
*,
repo_id: str,
url: str,
domains: list[str],
installed_version: str | None,
installed_manifest_version: str | None = None,
ref: str | None,
) -> None:
data = await self._load()
installed = data.get("installed_repos", {})
if not isinstance(installed, dict):
installed = {}
data["installed_repos"] = installed
installed[str(repo_id)] = {
"repo_id": str(repo_id),
"url": str(url),
"domains": [str(d) for d in (domains or []) if str(d).strip()],
"installed_at": int(time.time()),
# IMPORTANT: this is what BCS uses as "installed version" (ref/tag/branch)
"installed_version": installed_version,
# informational only
"installed_manifest_version": installed_manifest_version,
# keep ref too (debug/backward compatibility)
"ref": ref,
}
await self._save(data)
async def remove_installed_repo(self, repo_id: str) -> None:
data = await self._load()
installed = data.get("installed_repos", {})
if isinstance(installed, dict) and repo_id in installed:
installed.pop(repo_id, None)
data["installed_repos"] = installed
await self._save(data)

View File

@@ -290,3 +290,62 @@ class BCSReadmeView(HomeAssistantView):
md_str = str(md) md_str = str(md)
html = _render_markdown_server_side(md_str) html = _render_markdown_server_side(md_str)
return web.json_response({"ok": True, "readme": md_str, "html": html}) return web.json_response({"ok": True, "readme": md_str, "html": html})
class BCSInstallView(HomeAssistantView):
url = "/api/bcs/install"
name = "api:bcs_install"
requires_auth = True
def __init__(self, core: Any) -> None:
self.core: BCSCore = core
async def post(self, request: web.Request) -> web.Response:
repo_id = request.query.get("repo_id")
if not repo_id:
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
try:
result = await self.core.install_repo(repo_id)
return web.json_response(result, status=200)
except Exception as e:
_LOGGER.exception("BCS install failed: %s", e)
return web.json_response({"ok": False, "message": str(e) or "Install failed"}, status=500)
class BCSUpdateView(HomeAssistantView):
url = "/api/bcs/update"
name = "api:bcs_update"
requires_auth = True
def __init__(self, core: Any) -> None:
self.core: BCSCore = core
async def post(self, request: web.Request) -> web.Response:
repo_id = request.query.get("repo_id")
if not repo_id:
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
try:
result = await self.core.update_repo(repo_id)
return web.json_response(result, status=200)
except Exception as e:
_LOGGER.exception("BCS update failed: %s", e)
return web.json_response({"ok": False, "message": str(e) or "Update failed"}, status=500)
class BCSRestartView(HomeAssistantView):
url = "/api/bcs/restart"
name = "api:bcs_restart"
requires_auth = True
def __init__(self, core: Any) -> None:
self.core: BCSCore = core
async def post(self, request: web.Request) -> web.Response:
try:
await self.core.request_restart()
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)