Prepare blueprint install support

This commit is contained in:
2026-03-23 16:31:16 +01:00
parent 9448176ff4
commit 48f8ef6265
7 changed files with 200 additions and 48 deletions

View File

@@ -90,7 +90,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
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=111",
module_url="/api/bahmcloud_store_static/panel.js?v=112",
sidebar_title="Bahmcloud Store",
sidebar_icon="mdi:store",
require_admin=True,

View File

@@ -1172,13 +1172,17 @@ class BCSCore:
"latest_version": r.latest_version,
"latest_version_source": r.latest_version_source,
"category": r.meta_category,
"category_key": self._repo_install_type(r),
"install_target": self._repo_install_target(r),
"meta_author": r.meta_author,
"meta_maintainer": r.meta_maintainer,
"meta_source": r.meta_source,
"installed": installed,
"install_type": str(inst.get("install_type") if isinstance(inst, dict) else self._repo_install_type(r)),
"installed_version": installed_version,
"installed_manifest_version": installed_manifest_version,
"installed_domains": installed_domains,
"installed_paths": list(inst.get("installed_paths") or []) if isinstance(inst, dict) else [],
"favorite": self.is_favorite_repo(r.id),
}
)
@@ -1220,6 +1224,32 @@ class BCSCore:
return str(repo.default_branch).strip()
return "main"
@staticmethod
def _normalize_category_key(category: str | None) -> str:
raw = str(category or "").strip().lower()
if raw in ("integration", "integrations"):
return "integration"
if raw in ("blueprint", "blueprints"):
return "blueprint"
if raw in ("template", "templates"):
return "template"
if raw in ("lovelace", "dashboard", "dashboards", "lovelace design", "lovelace designs"):
return "lovelace"
return "integration"
def _repo_install_type(self, repo: RepoItem | None) -> str:
return self._normalize_category_key(getattr(repo, "meta_category", None))
def _repo_install_target(self, repo: RepoItem | None) -> str:
install_type = self._repo_install_type(repo)
if install_type == "blueprint":
return "/config/blueprints"
if install_type == "template":
return "/config"
if install_type == "lovelace":
return "/config"
return "/config/custom_components"
def _build_zip_url(self, repo_url: str, ref: str) -> str:
ref = (ref or "").strip()
if not ref:
@@ -1286,6 +1316,18 @@ class BCSCore:
return candidate
return None
@staticmethod
def _find_blueprints_root(extract_root: Path) -> Path | None:
direct = extract_root / "blueprints"
if direct.exists() and direct.is_dir():
return direct
for child in extract_root.iterdir():
candidate = child / "blueprints"
if candidate.exists() and candidate.is_dir():
return candidate
return None
async def _ensure_backup_root(self) -> None:
"""Create backup root directory if needed."""
def _mkdir() -> None:
@@ -1640,15 +1682,25 @@ class BCSCore:
for it in items:
domains = [str(d) for d in (it.domains or []) if str(d).strip()]
install_type = str(getattr(it, "install_type", "integration") or "integration").strip() or "integration"
installed_paths = [str(p) for p in (getattr(it, "installed_paths", None) or []) if str(p).strip()]
# A repo is considered "present" if at least one of its domains
# exists and contains a manifest.json.
present = False
for d in domains:
p = cc_root / d
if p.is_dir() and (p / "manifest.json").exists():
present = True
break
if install_type == "integration":
for d in domains:
p = cc_root / d
if p.is_dir() and (p / "manifest.json").exists():
present = True
break
else:
cfg_root = Path(self.hass.config.path(""))
for rel in installed_paths:
p = cfg_root / rel
if p.exists():
present = True
break
if not present:
to_remove.append(it.repo_id)
@@ -1657,6 +1709,8 @@ class BCSCore:
cache[it.repo_id] = {
"installed": True,
"domains": domains,
"install_type": install_type,
"installed_paths": installed_paths,
"installed_version": it.installed_version,
"installed_manifest_version": it.installed_manifest_version,
"ref": it.ref,
@@ -1692,24 +1746,50 @@ class BCSCore:
if path.exists() and path.is_dir():
shutil.rmtree(path, ignore_errors=True)
for domain in inst.domains:
d = str(domain).strip()
if not d:
continue
target = cc_root / d
await self.hass.async_add_executor_job(_remove_dir, target)
removed.append(d)
def _remove_file(path: Path) -> None:
if path.exists() and path.is_file():
path.unlink(missing_ok=True)
def _prune_empty_parents(path: Path, stop_at: Path) -> None:
cur = path.parent
while cur != stop_at and str(cur).startswith(str(stop_at)):
try:
if any(cur.iterdir()):
break
cur.rmdir()
except Exception:
break
cur = cur.parent
install_type = str(getattr(inst, "install_type", "integration") or "integration").strip() or "integration"
if install_type == "integration":
for domain in inst.domains:
d = str(domain).strip()
if not d:
continue
target = cc_root / d
await self.hass.async_add_executor_job(_remove_dir, target)
removed.append(d)
elif install_type == "blueprint":
cfg_root = Path(self.hass.config.path(""))
blueprints_root = Path(self.hass.config.path("blueprints"))
for rel in [str(p).strip() for p in (inst.installed_paths or []) if str(p).strip()]:
target = cfg_root / rel
await self.hass.async_add_executor_job(_remove_file, target)
await self.hass.async_add_executor_job(_prune_empty_parents, target, blueprints_root)
removed.append(rel)
await self.storage.remove_installed_repo(repo_id)
await self._refresh_installed_cache()
# Show restart required in Settings.
if removed:
if removed and install_type == "integration":
self._mark_restart_required()
_LOGGER.info("BCS uninstall complete: repo_id=%s removed_domains=%s", repo_id, removed)
self.signal_updated()
return {"ok": True, "repo_id": repo_id, "removed": removed, "restart_required": bool(removed)}
return {"ok": True, "repo_id": repo_id, "removed": removed, "restart_required": bool(removed) if install_type == "integration" else False}
async def install_repo(self, repo_id: str, *, version: str | None = None) -> dict[str, Any]:
repo = self.get_repo(repo_id)
@@ -1724,7 +1804,9 @@ class BCSCore:
_LOGGER.info("BCS install started: repo_id=%s ref=%s zip_url=%s", repo_id, ref, zip_url)
installed_domains: list[str] = []
installed_paths: list[str] = []
backups: dict[str, Path] = {}
install_type = self._repo_install_type(repo)
inst_before = self.get_installed(repo_id) or {}
backup_meta = {
@@ -1745,57 +1827,85 @@ class BCSCore:
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")
if install_type == "blueprint":
blueprints_root = self._find_blueprints_root(extract_dir)
if not blueprints_root:
raise BCSInstallError("blueprints folder not found in repository ZIP")
dest_root = Path(self.hass.config.path("custom_components"))
config_root = Path(self.hass.config.path(""))
target_root = Path(self.hass.config.path("blueprints"))
for domain_dir in cc_root.iterdir():
if not domain_dir.is_dir():
continue
manifest = domain_dir / "manifest.json"
if not manifest.exists():
continue
def _copy_blueprints() -> list[str]:
copied: list[str] = []
target_root.mkdir(parents=True, exist_ok=True)
for src in blueprints_root.rglob("*"):
if not src.is_file():
continue
rel = src.relative_to(blueprints_root)
dest = target_root / rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
copied.append(str(Path("blueprints") / rel).replace("\\", "/"))
return copied
domain = domain_dir.name
target = dest_root / domain
installed_paths = await self.hass.async_add_executor_job(_copy_blueprints)
if not installed_paths:
raise BCSInstallError("No blueprint files found under blueprints/")
else:
cc_root = self._find_custom_components_root(extract_dir)
if not cc_root:
raise BCSInstallError("custom_components folder not found in repository ZIP")
# Backup only if we are going to overwrite an existing domain.
if target.exists() and target.is_dir():
m = dict(backup_meta)
m["domain"] = domain
bkp = await self._backup_domain(domain, meta=m)
if bkp:
backups[domain] = bkp
else:
created_new.add(domain)
dest_root = Path(self.hass.config.path("custom_components"))
await self._copy_domain_dir(domain_dir, domain)
installed_domains.append(domain)
for domain_dir in cc_root.iterdir():
if not domain_dir.is_dir():
continue
manifest = domain_dir / "manifest.json"
if not manifest.exists():
continue
if not installed_domains:
raise BCSInstallError("No integrations found under custom_components/ (missing manifest.json)")
domain = domain_dir.name
target = dest_root / domain
installed_manifest_version = await self._read_installed_manifest_version(installed_domains[0])
# Backup only if we are going to overwrite an existing domain.
if target.exists() and target.is_dir():
m = dict(backup_meta)
m["domain"] = domain
bkp = await self._backup_domain(domain, meta=m)
if bkp:
backups[domain] = bkp
else:
created_new.add(domain)
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_manifest_version = await self._read_installed_manifest_version(installed_domains[0]) if installed_domains else None
installed_version = ref
await self.storage.set_installed_repo(
repo_id=repo_id,
url=repo.url,
domains=installed_domains,
install_type=install_type,
installed_paths=installed_paths,
installed_version=installed_version,
installed_manifest_version=installed_manifest_version,
ref=ref,
)
await self._refresh_installed_cache()
self._mark_restart_required()
if install_type == "integration":
self._mark_restart_required()
_LOGGER.info(
"BCS install complete: repo_id=%s domains=%s installed_ref=%s manifest_version=%s",
repo_id,
installed_domains,
installed_domains if installed_domains else installed_paths,
installed_version,
installed_manifest_version,
)
@@ -1804,9 +1914,11 @@ class BCSCore:
"ok": True,
"repo_id": repo_id,
"domains": installed_domains,
"installed_paths": installed_paths,
"install_type": install_type,
"installed_version": installed_version,
"installed_manifest_version": installed_manifest_version,
"restart_required": True,
"restart_required": True if install_type == "integration" else False,
}
except Exception as e:

View File

@@ -1270,6 +1270,7 @@ class BahmcloudStorePanel extends HTMLElement {
this._safeText(r?.latest_version) ? `Latest: ${this._safeText(r?.latest_version)}` : "Latest: -",
this._safeText(r?.provider) ? `Provider: ${this._safeText(r?.provider)}` : null,
this._safeText(r?.category) ? `Category: ${this._safeText(r?.category)}` : null,
this._safeText(r?.install_target) ? `Target: ${this._safeText(r?.install_target)}` : null,
this._safeText(r?.meta_author) ? `Author: ${this._safeText(r?.meta_author)}` : null,
this._safeText(r?.meta_maintainer) ? `Maintainer: ${this._safeText(r?.meta_maintainer)}` : null,
this._safeText(r?.meta_source) ? `Meta: ${this._safeText(r?.meta_source)}` : null,
@@ -1312,8 +1313,10 @@ class BahmcloudStorePanel extends HTMLElement {
const installed = this._asBoolStrict(r?.installed);
const installedVersion = this._safeText(r?.installed_version);
const installedDomains = Array.isArray(r?.installed_domains) ? r.installed_domains : [];
const installedPaths = Array.isArray(r?.installed_paths) ? r.installed_paths : [];
const latestVersion = this._safeText(r?.latest_version);
const favorite = this._asBoolStrict(r?.favorite) || this._isFavoriteRepo(repoId);
const installType = this._safeText(r?.install_type) || "integration";
const busyInstall = this._installingRepoId === repoId;
const busyUpdate = this._updatingRepoId === repoId;
@@ -1376,7 +1379,9 @@ class BahmcloudStorePanel extends HTMLElement {
const installBtn = `<button class="primary" id="btnInstall" ${installed || busy ? "disabled" : ""}>${busyInstall ? "Installing…" : installed ? "Installed" : "Install"}</button>`;
const updateBtn = `<button class="primary" id="btnUpdate" ${!updateAvailable || busy ? "disabled" : ""}>${busyUpdate ? "Updating…" : updateAvailable ? "Update" : "Up to date"}</button>`;
const uninstallBtn = `<button class="primary" id="btnUninstall" ${!installed || busy ? "disabled" : ""}>${busyUninstall ? "Uninstalling…" : "Uninstall"}</button>`;
const restoreBtn = `<button class="primary" id="btnRestore" ${!installed || busy ? "disabled" : ""}>Restore</button>`;
const restoreBtn = installType === "integration"
? `<button class="primary" id="btnRestore" ${!installed || busy ? "disabled" : ""}>Restore</button>`
: ``;
const favoriteBtn = `<button id="btnFavorite">${favorite ? "Unpin" : "Pin"}</button>`;
const restartHint = this._restartRequired
@@ -1424,6 +1429,7 @@ class BahmcloudStorePanel extends HTMLElement {
<div><strong>Installed version:</strong> ${this._esc(installedVersion || "-")}</div>
<div><strong>Latest version:</strong> ${this._esc(latestVersion || "-")}</div>
<div style="margin-top:6px;"><strong>Domains:</strong> ${installedDomains.length ? this._esc(installedDomains.join(", ")) : "-"}</div>
<div style="margin-top:6px;"><strong>Installed paths:</strong> ${installedPaths.length ? this._esc(installedPaths.join(", ")) : "-"}</div>
</div>
${versionSelect}

View File

@@ -25,6 +25,8 @@ class InstalledRepo:
url: str
domains: list[str]
installed_at: int
install_type: str = "integration"
installed_paths: list[str] | None = None
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
@@ -197,6 +199,11 @@ class BCSStorage:
if not isinstance(domains, list):
domains = []
domains = [str(d) for d in domains if str(d).strip()]
installed_paths = entry.get("installed_paths") or []
if not isinstance(installed_paths, list):
installed_paths = []
installed_paths = [str(p) for p in installed_paths if str(p).strip()]
install_type = str(entry.get("install_type") or "integration").strip() or "integration"
installed_version = entry.get("installed_version")
ref = entry.get("ref")
@@ -213,6 +220,8 @@ class BCSStorage:
url=str(entry.get("url") or ""),
domains=domains,
installed_at=int(entry.get("installed_at") or 0),
install_type=install_type,
installed_paths=installed_paths,
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,
@@ -238,9 +247,11 @@ class BCSStorage:
repo_id: str,
url: str,
domains: list[str],
installed_version: str | None,
installed_version: str | None = None,
installed_manifest_version: str | None = None,
ref: str | None,
ref: str | None = None,
install_type: str = "integration",
installed_paths: list[str] | None = None,
) -> None:
data = await self._load()
installed = data.get("installed_repos", {})
@@ -252,6 +263,8 @@ class BCSStorage:
"repo_id": str(repo_id),
"url": str(url),
"domains": [str(d) for d in (domains or []) if str(d).strip()],
"install_type": str(install_type or "integration"),
"installed_paths": [str(p) for p in (installed_paths or []) if str(p).strip()],
"installed_at": int(time.time()),
# IMPORTANT: this is what BCS uses as "installed version" (ref/tag/branch)
"installed_version": installed_version,
@@ -268,4 +281,4 @@ class BCSStorage:
if isinstance(installed, dict) and repo_id in installed:
installed.pop(repo_id, None)
data["installed_repos"] = installed
await self._save(data)
await self._save(data)

View File

@@ -540,6 +540,9 @@ class BCSRepoDetailView(HomeAssistantView):
domains = inst.get("domains") or []
if not isinstance(domains, list):
domains = []
installed_paths = inst.get("installed_paths") or []
if not isinstance(installed_paths, list):
installed_paths = []
return web.json_response({
"ok": True,
@@ -556,13 +559,17 @@ class BCSRepoDetailView(HomeAssistantView):
"latest_version": repo.latest_version,
"latest_version_source": repo.latest_version_source,
"category": repo.meta_category,
"category_key": self.core._repo_install_type(repo),
"install_target": self.core._repo_install_target(repo),
"meta_author": repo.meta_author,
"meta_maintainer": repo.meta_maintainer,
"meta_source": repo.meta_source,
"installed": installed,
"install_type": inst.get("install_type") or self.core._repo_install_type(repo),
"installed_version": inst.get("installed_version"),
"installed_manifest_version": inst.get("installed_manifest_version"),
"installed_domains": domains,
"installed_paths": installed_paths,
"favorite": self.core.is_favorite_repo(repo.id),
}
}, status=200)