diff --git a/custom_components/bahmcloud_store/__init__.py b/custom_components/bahmcloud_store/__init__.py index 07bf202..efbff00 100644 --- a/custom_components/bahmcloud_store/__init__.py +++ b/custom_components/bahmcloud_store/__init__.py @@ -1,75 +1,166 @@ from __future__ import annotations import logging -from datetime import timedelta +from dataclasses import dataclass +from typing import Any -from homeassistant.core import HomeAssistant -from homeassistant.components.panel_custom import async_register_panel -from homeassistant.helpers.event import async_track_time_interval +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 import EntityCategory -from .core import BCSCore, BCSConfig, BCSError +from .core import DOMAIN, SIGNAL_UPDATED, BCSCore _LOGGER = logging.getLogger(__name__) -DOMAIN = "bahmcloud_store" - -DEFAULT_STORE_URL = "https://git.bahmcloud.de/bahmcloud/ha_store/raw/branch/main/store.yaml" -CONF_STORE_URL = "store_url" - - -async def async_setup(hass: HomeAssistant, config: dict) -> bool: - cfg = config.get(DOMAIN, {}) or {} - store_url = cfg.get(CONF_STORE_URL, DEFAULT_STORE_URL) - - core = BCSCore(hass, BCSConfig(store_url=store_url)) - hass.data[DOMAIN] = core - - await core.async_initialize() - - from .views import ( - StaticAssetsView, - BCSApiView, - BCSReadmeView, - BCSCustomRepoView, - BCSInstallView, - BCSUpdateView, - BCSRestartView, - ) - - hass.http.register_view(StaticAssetsView()) - hass.http.register_view(BCSApiView(core)) - hass.http.register_view(BCSReadmeView(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( - hass, - frontend_url_path="bahmcloud-store", - webcomponent_name="bahmcloud-store-panel", - # IMPORTANT: bump v to avoid caching old JS - module_url="/api/bahmcloud_store_static/panel.js?v=101", - sidebar_title="Bahmcloud Store", - sidebar_icon="mdi:store", - require_admin=True, - config={}, - ) +def _pretty_repo_name(core: BCSCore, repo_id: str) -> str: + """Return a human-friendly name for a repo update entity.""" try: - await core.full_refresh(source="startup") - except BCSError as e: - _LOGGER.error("Initial refresh failed: %s", e) + 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() - 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: # pylint: disable=broad-exception-caught - _LOGGER.exception("Unexpected error during periodic refresh: %s", e) + await self._core.update_repo(self._repo_id) + finally: + self._in_progress = False + self.async_write_ha_state() - interval_seconds = int(getattr(core, "refresh_seconds", 300) or 300) - async_track_time_interval(hass, periodic, timedelta(seconds=interval_seconds)) - return True \ No newline at end of file +@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) \ No newline at end of file