Prepare blueprint install support
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user