diff --git a/custom_components/bahmcloud_store/core.py b/custom_components/bahmcloud_store/core.py index ed7c9ed..055e42d 100644 --- a/custom_components/bahmcloud_store/core.py +++ b/custom_components/bahmcloud_store/core.py @@ -93,7 +93,8 @@ class BCSCore: async def async_initialize(self) -> None: """Async initialization that avoids blocking file IO.""" self.version = await self._read_manifest_version_async() - await self._refresh_installed_cache() + # Keep installed state in sync with /config/custom_components. + await self.reconcile_installed() # After a successful HA restart, restart-required is no longer relevant. self._clear_restart_required_issue() @@ -155,6 +156,8 @@ class BCSCore: async def full_refresh(self, source: str = "manual") -> None: """Single refresh entry-point used by both timer and manual button.""" _LOGGER.info("BCS full refresh triggered (source=%s)", source) + # Keep installed state consistent even if users deleted folders manually. + await self.reconcile_installed() await self.refresh() self.signal_updated() @@ -514,6 +517,59 @@ class BCSCore: return await self.hass.async_add_executor_job(_read) + async def reconcile_installed(self) -> dict[str, int]: + """Reconcile installed repos with /config/custom_components. + + Users may delete integrations manually. In that case, our persistent + installed registry would be stale and the UI would incorrectly show + them as installed. We fix this by checking for each stored domain + directory whether it still exists and contains a manifest.json. + + Returns diagnostics: {"removed": X, "updated": Y}. + """ + removed = 0 + updated = 0 + + try: + items = await self.storage.list_installed_repos() + except Exception: + items = [] + + for it in items: + keep_domains: list[str] = [] + for domain in (it.domains or []): + d = str(domain).strip() + if not d: + continue + p = Path(self.hass.config.path("custom_components", d)) + if p.is_dir() and (p / "manifest.json").exists(): + keep_domains.append(d) + + if not keep_domains: + try: + await self.storage.remove_installed_repo(it.repo_id) + removed += 1 + except Exception: + pass + continue + + if keep_domains != (it.domains or []): + try: + await self.storage.set_installed_repo( + repo_id=it.repo_id, + url=it.url, + domains=keep_domains, + installed_version=it.installed_version, + installed_manifest_version=it.installed_manifest_version, + ref=it.ref or it.installed_version, + ) + updated += 1 + except Exception: + pass + + await self._refresh_installed_cache() + return {"removed": removed, "updated": updated} + async def _refresh_installed_cache(self) -> None: try: items = await self.storage.list_installed_repos() @@ -606,5 +662,48 @@ class BCSCore: _LOGGER.info("BCS update started: repo_id=%s", repo_id) return await self.install_repo(repo_id) + async def uninstall_repo(self, repo_id: str) -> dict[str, Any]: + """Uninstall a previously installed repository. + + This removes all integration folders (domains) that were installed + from the repo under /config/custom_components and clears the installed + marker in storage. + """ + async with self._install_lock: + installed = await self.storage.get_installed_repo(repo_id) + if not installed: + # Reconcile first (maybe it was removed manually). + await self.reconcile_installed() + installed = await self.storage.get_installed_repo(repo_id) + if not installed: + raise BCSInstallError(f"repo_id is not installed: {repo_id}") + + domains = [str(d) for d in (installed.domains or []) if str(d).strip()] + removed_domains: list[str] = [] + + def _remove_domain_dir(domain: str) -> None: + p = Path(self.hass.config.path("custom_components", domain)) + if p.exists() and p.is_dir(): + shutil.rmtree(p, ignore_errors=True) + + for domain in domains: + await self.hass.async_add_executor_job(_remove_domain_dir, domain) + removed_domains.append(domain) + + await self.storage.remove_installed_repo(repo_id) + await self._refresh_installed_cache() + + # Removing integrations also requires a restart to unload code cleanly. + self._mark_restart_required() + + _LOGGER.info("BCS uninstall complete: repo_id=%s domains=%s", repo_id, removed_domains) + self.signal_updated() + return { + "ok": True, + "repo_id": repo_id, + "domains": removed_domains, + "restart_required": True, + } + async def request_restart(self) -> None: await self.hass.services.async_call("homeassistant", "restart", {}, blocking=False) \ No newline at end of file