34 Commits
0.5.0 ... 0.5.3

Author SHA1 Message Date
c18e93406a 0.5.3 2026-01-16 19:20:07 +00:00
9af18ba090 0.5.3 2026-01-16 19:19:23 +00:00
fff50a1580 0.5.3 2026-01-16 19:18:47 +00:00
f8e9967c3a 0.5.3 2026-01-16 19:18:09 +00:00
7bc493eb45 0.5.3 2026-01-16 19:16:39 +00:00
b97b970a45 Dump 2026-01-16 19:16:01 +00:00
593e0c367d 0.5.3 2026-01-16 19:14:35 +00:00
8e0817a64b 0.5.3 2026-01-16 19:13:56 +00:00
dfc7e44565 0.5.3 2026-01-16 19:13:17 +00:00
c9c4f99fbf 0.5.3 2026-01-16 19:12:43 +00:00
37cc11c9ee 0.5.3 2026-01-16 19:12:10 +00:00
9c773c07e8 0.5.3 2026-01-16 19:11:26 +00:00
c04612e159 0.5.3 2026-01-16 19:10:35 +00:00
5796012189 0.5.3 2026-01-16 19:09:47 +00:00
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
12 changed files with 1711 additions and 1374 deletions

View File

@@ -11,6 +11,28 @@ Sections:
--- ---
## [0.5.3] - 2026-01-16
### Added
- Native Home Assistant Update entities for installed repositories (shown under **Settings → System → Updates**).
- Human-friendly update names based on repository name (instead of internal repo IDs like `index:1`).
### Changed
- Update UI now behaves like official Home Assistant integrations (update action is triggered via the HA Updates screen).
## [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

@@ -1,66 +1,166 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import timedelta from dataclasses import dataclass
from typing import Any
from homeassistant.core import HomeAssistant from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
from homeassistant.components.panel_custom import async_register_panel from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity import EntityCategory
from .core import BCSCore, BCSConfig, BCSError from .core import DOMAIN, SIGNAL_UPDATED, BCSCore
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DOMAIN = "bahmcloud_store"
DEFAULT_STORE_URL = "https://git.bahmcloud.de/bahmcloud/ha_store/raw/branch/main/store.yaml" def _pretty_repo_name(core: BCSCore, repo_id: str) -> str:
CONF_STORE_URL = "store_url" """Return a human-friendly name for a repo update entity."""
try:
repo = core.get_repo(repo_id)
if repo and getattr(repo, "name", None):
name = str(repo.name).strip()
if name:
return name
except Exception:
pass
# Fallbacks
if repo_id.startswith("index:"):
return f"BCS Index {repo_id.split(':', 1)[1]}"
if repo_id.startswith("custom:"):
return f"BCS Custom {repo_id.split(':', 1)[1]}"
return f"BCS {repo_id}"
async def async_setup(hass: HomeAssistant, config: dict) -> bool: @dataclass(frozen=True)
cfg = config.get(DOMAIN, {}) or {} class _RepoKey:
store_url = cfg.get(CONF_STORE_URL, DEFAULT_STORE_URL) repo_id: str
core = BCSCore(hass, BCSConfig(store_url=store_url))
hass.data[DOMAIN] = core
# Avoid blocking IO during setup class BCSRepoUpdateEntity(UpdateEntity):
await core.async_initialize() """Update entity representing a BCS-managed repository."""
# Register HTTP views and panel _attr_entity_category = EntityCategory.DIAGNOSTIC
from .views import StaticAssetsView, BCSApiView, BCSReadmeView, BCSCustomRepoView _attr_supported_features = UpdateEntityFeature.INSTALL
hass.http.register_view(StaticAssetsView()) def __init__(self, core: BCSCore, repo_id: str) -> None:
hass.http.register_view(BCSApiView(core)) self._core = core
hass.http.register_view(BCSReadmeView(core)) self._repo_id = repo_id
hass.http.register_view(BCSCustomRepoView(core)) self._in_progress = False
await async_register_panel( # Stable unique id (do NOT change)
hass, self._attr_unique_id = f"{DOMAIN}:{repo_id}"
frontend_url_path="bahmcloud-store",
webcomponent_name="bahmcloud-store-panel", # Human-friendly name in UI
module_url="/api/bahmcloud_store_static/panel.js?v=99", pretty = _pretty_repo_name(core, repo_id)
sidebar_title="Bahmcloud Store", self._attr_name = pretty
sidebar_icon="mdi:store",
require_admin=True, # Title shown in the entity dialog
config={}, self._attr_title = pretty
@property
def available(self) -> bool:
repo = self._core.get_repo(self._repo_id)
installed = self._core.get_installed(self._repo_id)
return repo is not None and installed is not None
@property
def in_progress(self) -> bool | None:
return self._in_progress
@property
def installed_version(self) -> str | None:
installed = self._core.get_installed(self._repo_id) or {}
v = installed.get("installed_version") or installed.get("ref")
return str(v) if v else None
@property
def latest_version(self) -> str | None:
repo = self._core.get_repo(self._repo_id)
if not repo:
return None
v = getattr(repo, "latest_version", None)
return str(v) if v else None
@property
def update_available(self) -> bool:
latest = self.latest_version
installed = self.installed_version
if not latest or not installed:
return False
return latest != installed
def version_is_newer(self, latest_version: str, installed_version: str) -> bool:
return latest_version != installed_version
@property
def release_url(self) -> str | None:
repo = self._core.get_repo(self._repo_id)
return getattr(repo, "url", None) if repo else None
async def async_install(self, version: str | None, backup: bool, **kwargs: Any) -> None:
if version is not None:
_LOGGER.debug(
"BCS update entity requested specific version=%s (ignored)", version
) )
# Initial refresh self._in_progress = True
self.async_write_ha_state()
try: try:
await core.full_refresh(source="startup") await self._core.update_repo(self._repo_id)
except BCSError as e: finally:
_LOGGER.error("Initial refresh failed: %s", e) self._in_progress = False
self.async_write_ha_state()
async def periodic(_now) -> None:
try:
await core.full_refresh(source="timer")
except BCSError as e:
_LOGGER.warning("Periodic refresh failed: %s", e)
except Exception as e:
_LOGGER.exception("Unexpected error during periodic refresh: %s", e)
interval_seconds = int(getattr(core, "refresh_seconds", 300) or 300) @callback
async_track_time_interval(hass, periodic, timedelta(seconds=interval_seconds)) def _sync_entities(
core: BCSCore,
existing: dict[str, BCSRepoUpdateEntity],
async_add_entities: AddEntitiesCallback,
) -> None:
"""Ensure there is one update entity per installed repo."""
installed_map = getattr(core, "_installed_cache", {}) or {}
new_entities: list[BCSRepoUpdateEntity] = []
return True for repo_id, data in installed_map.items():
if not isinstance(data, dict):
continue
if repo_id in existing:
continue
ent = BCSRepoUpdateEntity(core, repo_id)
existing[repo_id] = ent
new_entities.append(ent)
if new_entities:
async_add_entities(new_entities)
for ent in existing.values():
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."""
core: BCSCore | None = hass.data.get(DOMAIN)
if not core:
_LOGGER.debug("BCS core not available, skipping update platform setup")
return
entities: dict[str, BCSRepoUpdateEntity] = {}
_sync_entities(core, entities, async_add_entities)
@callback
def _handle_update() -> None:
_sync_entities(core, entities, async_add_entities)
async_dispatcher_connect(hass, SIGNAL_UPDATED, _handle_update)

View File

@@ -1,328 +1,166 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import hashlib
import json
import logging import logging
import time
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from homeassistant.core import HomeAssistant from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.core import HomeAssistant, callback
from homeassistant.util import yaml as ha_yaml from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity import EntityCategory
from .storage import BCSStorage, CustomRepo from .core import DOMAIN, SIGNAL_UPDATED, BCSCore
from .providers import fetch_repo_info, detect_provider, RepoInfo, fetch_readme_markdown
from .metadata import fetch_repo_metadata, RepoMetadata
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DOMAIN = "bahmcloud_store"
def _pretty_repo_name(core: BCSCore, repo_id: str) -> str:
class BCSError(Exception): """Return a human-friendly name for a repo update entity."""
"""BCS core error."""
@dataclass
class BCSConfig:
store_url: str
@dataclass
class RepoItem:
id: str
name: str
url: str
source: str # "index" | "custom"
owner: str | None = None
provider: str | None = None
provider_repo_name: str | None = None
provider_description: str | None = None
default_branch: str | None = None
latest_version: str | None = None
latest_version_source: str | None = None # "release" | "tag" | "atom" | None
meta_source: str | None = None
meta_name: str | None = None
meta_description: str | None = None
meta_category: str | None = None
meta_author: str | None = None
meta_maintainer: str | None = None
class BCSCore:
def __init__(self, hass: HomeAssistant, config: BCSConfig) -> None:
self.hass = hass
self.config = config
self.storage = BCSStorage(hass)
self.refresh_seconds: int = 300
self.repos: dict[str, RepoItem] = {}
self._listeners: list[callable] = []
# Will be loaded asynchronously (no blocking IO in event loop)
self.version: str = "unknown"
# Diagnostics (helps verify refresh behavior)
self.last_index_url: str | None = None
self.last_index_bytes: int | None = None
self.last_index_hash: str | None = None
self.last_index_loaded_at: float | None = None
async def async_initialize(self) -> None:
"""Async initialization that avoids blocking file IO."""
self.version = await self._read_manifest_version_async()
async def _read_manifest_version_async(self) -> str:
def _read() -> str:
try: try:
manifest_path = Path(__file__).resolve().parent / "manifest.json" repo = core.get_repo(repo_id)
data = json.loads(manifest_path.read_text(encoding="utf-8")) if repo and getattr(repo, "name", None):
v = data.get("version") name = str(repo.name).strip()
return str(v) if v else "unknown" if name:
except Exception: return name
return "unknown"
return await self.hass.async_add_executor_job(_read)
def add_listener(self, cb) -> None:
self._listeners.append(cb)
def signal_updated(self) -> None:
for cb in list(self._listeners):
try:
cb()
except Exception: except Exception:
pass pass
async def full_refresh(self, source: str = "manual") -> None: # Fallbacks
"""Single refresh entry-point used by both timer and manual button.""" if repo_id.startswith("index:"):
_LOGGER.info("BCS full refresh triggered (source=%s)", source) return f"BCS Index {repo_id.split(':', 1)[1]}"
await self.refresh() if repo_id.startswith("custom:"):
self.signal_updated() return f"BCS Custom {repo_id.split(':', 1)[1]}"
return f"BCS {repo_id}"
def get_repo(self, repo_id: str) -> RepoItem | None:
return self.repos.get(repo_id)
async def refresh(self) -> None: @dataclass(frozen=True)
index_repos, refresh_seconds = await self._load_index_repos() class _RepoKey:
self.refresh_seconds = refresh_seconds repo_id: str
custom_repos = await self.storage.list_custom_repos()
merged: dict[str, RepoItem] = {} class BCSRepoUpdateEntity(UpdateEntity):
"""Update entity representing a BCS-managed repository."""
for item in index_repos: _attr_entity_category = EntityCategory.DIAGNOSTIC
merged[item.id] = item _attr_supported_features = UpdateEntityFeature.INSTALL
for c in custom_repos: def __init__(self, core: BCSCore, repo_id: str) -> None:
merged[c.id] = RepoItem( self._core = core
id=c.id, self._repo_id = repo_id
name=(c.name or c.url), self._in_progress = False
url=c.url,
source="custom",
)
for r in merged.values(): # Stable unique id (do NOT change)
r.provider = detect_provider(r.url) self._attr_unique_id = f"{DOMAIN}:{repo_id}"
await self._enrich_and_resolve(merged) # Human-friendly name in UI
self.repos = merged pretty = _pretty_repo_name(core, repo_id)
self._attr_name = pretty
_LOGGER.info( # Title shown in the entity dialog
"BCS refresh complete: repos=%s (index=%s, custom=%s)", self._attr_title = pretty
len(self.repos),
len([r for r in self.repos.values() if r.source == "index"]),
len([r for r in self.repos.values() if r.source == "custom"]),
)
async def _enrich_and_resolve(self, merged: dict[str, RepoItem]) -> None: @property
sem = asyncio.Semaphore(6) def available(self) -> bool:
repo = self._core.get_repo(self._repo_id)
installed = self._core.get_installed(self._repo_id)
return repo is not None and installed is not None
async def process_one(r: RepoItem) -> None: @property
async with sem: def in_progress(self) -> bool | None:
info: RepoInfo = await fetch_repo_info(self.hass, r.url) return self._in_progress
r.provider = info.provider or r.provider @property
r.owner = info.owner or r.owner def installed_version(self) -> str | None:
r.provider_repo_name = info.repo_name installed = self._core.get_installed(self._repo_id) or {}
r.provider_description = info.description v = installed.get("installed_version") or installed.get("ref")
r.default_branch = info.default_branch or r.default_branch return str(v) if v else None
r.latest_version = info.latest_version @property
r.latest_version_source = info.latest_version_source def latest_version(self) -> str | None:
repo = self._core.get_repo(self._repo_id)
md: RepoMetadata = await fetch_repo_metadata(self.hass, r.url, r.default_branch)
r.meta_source = md.source
r.meta_name = md.name
r.meta_description = md.description
r.meta_category = md.category
r.meta_author = md.author
r.meta_maintainer = md.maintainer
has_user_or_index_name = bool(r.name) and (r.name != r.url) and (not str(r.name).startswith("http"))
if r.meta_name:
r.name = r.meta_name
elif not has_user_or_index_name and r.provider_repo_name:
r.name = r.provider_repo_name
elif not r.name:
r.name = r.url
await asyncio.gather(*(process_one(r) for r in merged.values()), return_exceptions=True)
def _add_cache_buster(self, url: str) -> str:
parts = urlsplit(url)
q = dict(parse_qsl(parts.query, keep_blank_values=True))
q["t"] = str(int(time.time()))
new_query = urlencode(q)
return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment))
def _gitea_src_to_raw(self, url: str) -> str:
parts = urlsplit(url)
path = parts.path
path2 = path.replace("/src/branch/", "/raw/branch/")
if path2 == path:
return url
return urlunsplit((parts.scheme, parts.netloc, path2, parts.query, parts.fragment))
async def _fetch_store_text(self, url: str) -> str:
session = async_get_clientsession(self.hass)
headers = {
"User-Agent": "BahmcloudStore (Home Assistant)",
"Cache-Control": "no-cache, no-store, max-age=0",
"Pragma": "no-cache",
"Expires": "0",
}
async with session.get(url, timeout=30, headers=headers) as resp:
if resp.status != 200:
raise BCSError(f"store_url returned {resp.status}")
return await resp.text()
async def _load_index_repos(self) -> tuple[list[RepoItem], int]:
store_url = (self.config.store_url or "").strip()
if not store_url:
raise BCSError("store_url is empty")
url = self._add_cache_buster(store_url)
try:
raw = await self._fetch_store_text(url)
# If we fetched a HTML page (wrong endpoint), attempt raw conversion.
if "<html" in raw.lower() or "<!doctype html" in raw.lower():
fallback = self._add_cache_buster(self._gitea_src_to_raw(store_url))
if fallback != url:
_LOGGER.warning("BCS store index looked like HTML, retrying raw URL")
raw = await self._fetch_store_text(fallback)
url = fallback
except Exception as e:
raise BCSError(f"Failed fetching store index: {e}") from e
# Diagnostics
b = raw.encode("utf-8", errors="replace")
h = hashlib.sha256(b).hexdigest()[:12]
self.last_index_url = url
self.last_index_bytes = len(b)
self.last_index_hash = h
self.last_index_loaded_at = time.time()
_LOGGER.info(
"BCS index loaded: url=%s bytes=%s sha=%s",
self.last_index_url,
self.last_index_bytes,
self.last_index_hash,
)
try:
data = ha_yaml.parse_yaml(raw)
if not isinstance(data, dict):
raise BCSError("store.yaml must be a mapping")
refresh_seconds = int(data.get("refresh_seconds", 300))
repos = data.get("repos", [])
if not isinstance(repos, list):
raise BCSError("store.yaml 'repos' must be a list")
items: list[RepoItem] = []
for i, r in enumerate(repos):
if not isinstance(r, dict):
continue
repo_url = str(r.get("url", "")).strip()
if not repo_url:
continue
name = str(r.get("name") or repo_url).strip()
items.append(
RepoItem(
id=f"index:{i}",
name=name,
url=repo_url,
source="index",
)
)
_LOGGER.info("BCS index parsed: repos=%s refresh_seconds=%s", len(items), refresh_seconds)
return items, refresh_seconds
except Exception as e:
raise BCSError(f"Invalid store.yaml: {e}") from e
async def add_custom_repo(self, url: str, name: str | None) -> CustomRepo:
url = str(url or "").strip()
if not url:
raise BCSError("Missing url")
c = await self.storage.add_custom_repo(url, name)
await self.full_refresh(source="custom_repo_add")
return c
async def remove_custom_repo(self, repo_id: str) -> None:
await self.storage.remove_custom_repo(repo_id)
await self.full_refresh(source="custom_repo_remove")
async def list_custom_repos(self) -> list[CustomRepo]:
return await self.storage.list_custom_repos()
def list_repos_public(self) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for r in self.repos.values():
out.append(
{
"id": r.id,
"name": r.name,
"url": r.url,
"source": r.source,
"owner": r.owner,
"provider": r.provider,
"repo_name": r.provider_repo_name,
"description": r.provider_description or r.meta_description,
"default_branch": r.default_branch,
"latest_version": r.latest_version,
"latest_version_source": r.latest_version_source,
"category": r.meta_category,
"meta_author": r.meta_author,
"meta_maintainer": r.meta_maintainer,
"meta_source": r.meta_source,
}
)
return out
async def fetch_readme_markdown(self, repo_id: str) -> str | None:
repo = self.get_repo(repo_id)
if not repo: if not repo:
return None return None
v = getattr(repo, "latest_version", None)
return str(v) if v else None
return await fetch_readme_markdown( @property
self.hass, def update_available(self) -> bool:
repo.url, latest = self.latest_version
provider=repo.provider, installed = self.installed_version
default_branch=repo.default_branch, if not latest or not installed:
return False
return latest != installed
def version_is_newer(self, latest_version: str, installed_version: str) -> bool:
return latest_version != installed_version
@property
def release_url(self) -> str | None:
repo = self._core.get_repo(self._repo_id)
return getattr(repo, "url", None) if repo else None
async def async_install(self, version: str | None, backup: bool, **kwargs: Any) -> None:
if version is not None:
_LOGGER.debug(
"BCS update entity requested specific version=%s (ignored)", version
) )
self._in_progress = True
self.async_write_ha_state()
try:
await self._core.update_repo(self._repo_id)
finally:
self._in_progress = False
self.async_write_ha_state()
@callback
def _sync_entities(
core: BCSCore,
existing: dict[str, BCSRepoUpdateEntity],
async_add_entities: AddEntitiesCallback,
) -> None:
"""Ensure there is one update entity per installed repo."""
installed_map = getattr(core, "_installed_cache", {}) or {}
new_entities: list[BCSRepoUpdateEntity] = []
for repo_id, data in installed_map.items():
if not isinstance(data, dict):
continue
if repo_id in existing:
continue
ent = BCSRepoUpdateEntity(core, repo_id)
existing[repo_id] = ent
new_entities.append(ent)
if new_entities:
async_add_entities(new_entities)
for ent in existing.values():
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."""
core: BCSCore | None = hass.data.get(DOMAIN)
if not core:
_LOGGER.debug("BCS core not available, skipping update platform setup")
return
entities: dict[str, BCSRepoUpdateEntity] = {}
_sync_entities(core, entities, async_add_entities)
@callback
def _handle_update() -> None:
_sync_entities(core, entities, async_add_entities)
async_dispatcher_connect(hass, SIGNAL_UPDATED, _handle_update)

View File

@@ -1,8 +1,9 @@
{ {
"domain": "bahmcloud_store", "domain": "bahmcloud_store",
"name": "Bahmcloud Store", "name": "Bahmcloud Store",
"version": "0.5.0", "version": "0.5.3",
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store", "documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
"platforms": ["update"],
"requirements": [], "requirements": [],
"codeowners": ["@bahmcloud"], "codeowners": ["@bahmcloud"],
"iot_class": "local_polling" "iot_class": "local_polling"

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

@@ -0,0 +1,8 @@
{
"issues": {
"restart_required": {
"title": "Restart required",
"description": "One or more integrations were installed or updated by Bahmcloud Store. Restart Home Assistant to load the changes."
}
}
}

View File

@@ -1,11 +1,146 @@
from __future__ import annotations from __future__ import annotations
# NOTE: import logging
# Update entities will be implemented once installation/provider resolution is in place. from dataclasses import dataclass
# This stub prevents platform load errors and keeps the integration stable in 0.3.0. from typing import Any
from homeassistant.core import HomeAssistant from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
from homeassistant.core import HomeAssistant, callback
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 .core import DOMAIN, SIGNAL_UPDATED, BCSCore
_LOGGER = logging.getLogger(__name__)
def _pretty_repo_name(core: BCSCore, repo_id: str) -> str:
"""Return a human-friendly name for a repo update entity."""
try:
repo = core.get_repo(repo_id)
if repo and getattr(repo, "name", None):
name = str(repo.name).strip()
if name:
return name
except Exception:
pass
# Fallbacks
if repo_id.startswith("index:"):
return f"BCS Index {repo_id.split(':', 1)[1]}"
if repo_id.startswith("custom:"):
return f"BCS Custom {repo_id.split(':', 1)[1]}"
return f"BCS {repo_id}"
@dataclass(frozen=True)
class _RepoKey:
repo_id: str
class BCSRepoUpdateEntity(UpdateEntity):
"""Update entity representing a BCS-managed repository."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_supported_features = UpdateEntityFeature.INSTALL
def __init__(self, core: BCSCore, repo_id: str) -> None:
self._core = core
self._repo_id = repo_id
self._in_progress = False
# Stable unique id (do NOT change)
self._attr_unique_id = f"{DOMAIN}:{repo_id}"
# Human-friendly name in UI
pretty = _pretty_repo_name(core, repo_id)
self._attr_name = pretty
# Title shown in the entity dialog
self._attr_title = pretty
@property
def available(self) -> bool:
repo = self._core.get_repo(self._repo_id)
installed = self._core.get_installed(self._repo_id)
return repo is not None and installed is not None
@property
def in_progress(self) -> bool | None:
return self._in_progress
@property
def installed_version(self) -> str | None:
installed = self._core.get_installed(self._repo_id) or {}
v = installed.get("installed_version") or installed.get("ref")
return str(v) if v else None
@property
def latest_version(self) -> str | None:
repo = self._core.get_repo(self._repo_id)
if not repo:
return None
v = getattr(repo, "latest_version", None)
return str(v) if v else None
@property
def update_available(self) -> bool:
latest = self.latest_version
installed = self.installed_version
if not latest or not installed:
return False
return latest != installed
def version_is_newer(self, latest_version: str, installed_version: str) -> bool:
return latest_version != installed_version
@property
def release_url(self) -> str | None:
repo = self._core.get_repo(self._repo_id)
return getattr(repo, "url", None) if repo else None
async def async_install(self, version: str | None, backup: bool, **kwargs: Any) -> None:
if version is not None:
_LOGGER.debug(
"BCS update entity requested specific version=%s (ignored)", version
)
self._in_progress = True
self.async_write_ha_state()
try:
await self._core.update_repo(self._repo_id)
finally:
self._in_progress = False
self.async_write_ha_state()
@callback
def _sync_entities(
core: BCSCore,
existing: dict[str, BCSRepoUpdateEntity],
async_add_entities: AddEntitiesCallback,
) -> None:
"""Ensure there is one update entity per installed repo."""
installed_map = getattr(core, "_installed_cache", {}) or {}
new_entities: list[BCSRepoUpdateEntity] = []
for repo_id, data in installed_map.items():
if not isinstance(data, dict):
continue
if repo_id in existing:
continue
ent = BCSRepoUpdateEntity(core, repo_id)
existing[repo_id] = ent
new_entities.append(ent)
if new_entities:
async_add_entities(new_entities)
for ent in existing.values():
ent.async_write_ha_state()
async def async_setup_platform( async def async_setup_platform(
@@ -14,4 +149,18 @@ async def async_setup_platform(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
discovery_info=None, discovery_info=None,
): ):
"""Set up BCS update entities."""
core: BCSCore | None = hass.data.get(DOMAIN)
if not core:
_LOGGER.debug("BCS core not available, skipping update platform setup")
return return
entities: dict[str, BCSRepoUpdateEntity] = {}
_sync_entities(core, entities, async_add_entities)
@callback
def _handle_update() -> None:
_sync_entities(core, entities, async_add_entities)
async_dispatcher_connect(hass, SIGNAL_UPDATED, _handle_update)

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)