12 Commits
0.5.0 ... main

Author SHA1 Message Date
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
6 changed files with 1361 additions and 1011 deletions

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,10 +66,10 @@ 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)
async_track_time_interval(hass, periodic, timedelta(seconds=interval_seconds)) async_track_time_interval(hass, periodic, timedelta(seconds=interval_seconds))
return True return True

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,24 +305,43 @@ 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
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()]
v = inst.get('installed_version')
installed_version = str(v) if v is not None else None
out.append( out.append(
{ {
"id": r.id, 'id': r.id,
"name": r.name, 'name': r.name,
"url": r.url, 'url': r.url,
"source": r.source, 'source': r.source,
"owner": r.owner, 'owner': r.owner,
"provider": r.provider, 'provider': r.provider,
"repo_name": r.provider_repo_name, 'repo_name': r.provider_repo_name,
"description": r.provider_description or r.meta_description, 'description': r.provider_description or r.meta_description,
"default_branch": r.default_branch, 'default_branch': r.default_branch,
"latest_version": r.latest_version, 'latest_version': r.latest_version,
"latest_version_source": r.latest_version_source, 'latest_version_source': r.latest_version_source,
"category": r.meta_category, 'category': r.meta_category,
"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_domains': installed_domains,
} }
) )
return out return out
@@ -326,3 +357,205 @@ 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_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,
"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)")
installed_version = await self._read_installed_version(installed_domains[0])
await self.storage.set_installed_repo(
repo_id=repo_id,
url=repo.url,
domains=installed_domains,
installed_version=installed_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", repo_id, installed_domains)
self.signal_updated()
return {
"ok": True,
"repo_id": repo_id,
"domains": installed_domains,
"installed_version": installed_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)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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,39 @@ 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
ref: str | None = None
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 +64,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 +90,78 @@ 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()]
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(entry.get("installed_version")) if entry.get("installed_version") else None,
ref=str(entry.get("ref")) if entry.get("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 repo_id in list(installed.keys()):
item = await self.get_installed_repo(str(repo_id))
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,
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()),
"installed_version": installed_version,
"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)