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

3
.idea/changes.md generated
View File

@@ -15,6 +15,9 @@
- Added a persistent language rule that all project-facing repository content must stay in English regardless of the chat language. - Added a persistent language rule that all project-facing repository content must stay in English regardless of the chat language.
- Added persistent pinned repositories support: favorites are stored in settings, exposed by the backend, filterable and sortable in the store view, and toggleable from the detail view without forcing a full repository refresh. - Added persistent pinned repositories support: favorites are stored in settings, exposed by the backend, filterable and sortable in the store view, and toggleable from the detail view without forcing a full repository refresh.
- Bumped the integration version from `0.7.3` to `0.7.4` and added the `0.7.4` release entry to `CHANGELOG.md` for the pinned-repositories feature. - Bumped the integration version from `0.7.3` to `0.7.4` and added the `0.7.4` release entry to `CHANGELOG.md` for the pinned-repositories feature.
- Added the broader product roadmap to the persistent project prompt: blueprints, templates, Lovelace designs, and more category support as future store targets.
- Started the blueprint/category-aware installer preparation: installation metadata now distinguishes install type and installed paths, repo payloads expose install targets, and the active panel shows install-target context for future non-integration categories.
- Added initial blueprint install-path handling groundwork so the codebase is no longer fully hard-wired to `custom_components/`.
### Documented ### Documented
- Captured the verified project identity from the repository and README files: Bahmcloud Store is a Home Assistant custom integration intended to behave like a provider-neutral store for custom integrations, similar to HACS but broader than GitHub-only workflows. - Captured the verified project identity from the repository and README files: Bahmcloud Store is a Home Assistant custom integration intended to behave like a provider-neutral store for custom integrations, similar to HACS but broader than GitHub-only workflows.

11
.idea/start prompt.md generated
View File

@@ -6,6 +6,7 @@ Project identity:
- This is a Home Assistant custom integration named `bahmcloud_store`. - This is a Home Assistant custom integration named `bahmcloud_store`.
- The product goal is a provider-neutral store for Home Assistant custom integrations, similar in spirit to HACS, but not limited to GitHub. - The product goal is a provider-neutral store for Home Assistant custom integrations, similar in spirit to HACS, but not limited to GitHub.
- Current real provider implementation is strongest for GitHub, GitLab, and Gitea-compatible providers. Unknown providers currently fall through the Gitea-style code paths, so do not assume every arbitrary Git provider works without verification. - Current real provider implementation is strongest for GitHub, GitLab, and Gitea-compatible providers. Unknown providers currently fall through the Gitea-style code paths, so do not assume every arbitrary Git provider works without verification.
- The long-term product direction is broader than integrations only: the store should evolve into a Home Assistant content store that can manage integrations, blueprints, templates, Lovelace designs, and additional future categories from multiple git providers.
Working rules: Working rules:
- All project-facing work must be done in English only, regardless of the language the user speaks in chat. This applies to code comments, documentation, changelog entries, release notes, commit messages, PR text, UI text, issue text, and any other project artifacts unless the user explicitly requests a specific exception for repository content. - All project-facing work must be done in English only, regardless of the language the user speaks in chat. This applies to code comments, documentation, changelog entries, release notes, commit messages, PR text, UI text, issue text, and any other project artifacts unless the user explicitly requests a specific exception for repository content.
@@ -102,6 +103,16 @@ Code-analysis findings that should influence future work:
- The end-user and full READMEs contain some stale or inconsistent details compared with the current UI and code. Verify behavior in source before using README text as specification. - The end-user and full READMEs contain some stale or inconsistent details compared with the current UI and code. Verify behavior in source before using README text as specification.
- There are visible encoding/mojibake issues in some documentation and older UI assets. Preserve valid UTF-8 when editing. - There are visible encoding/mojibake issues in some documentation and older UI assets. Preserve valid UTF-8 when editing.
Planned product expansion:
1. Add support for Home Assistant blueprints and install them directly into the correct Home Assistant blueprint location from the store.
2. Add templates and Lovelace designs to the store so they can be discovered and installed from the same UI.
3. Add support for more categories beyond integrations and design the architecture so category-specific install targets and validation rules are explicit.
Implications for future architecture:
- The current install pipeline is integration-centric because it assumes repository content under `custom_components/`.
- Future category support should move toward category-aware install strategies instead of one universal install path.
- Store metadata and index entries will likely need stronger category typing, install-target definitions, and validation rules per category.
Project constraints to respect in future edits: Project constraints to respect in future edits:
- Keep async I/O non-blocking in Home Assistant. - Keep async I/O non-blocking in Home Assistant.
- Avoid startup-heavy network work before HA is fully started. - Avoid startup-heavy network work before HA is fully started.

View File

@@ -90,7 +90,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
frontend_url_path="bahmcloud-store", frontend_url_path="bahmcloud-store",
webcomponent_name="bahmcloud-store-panel", webcomponent_name="bahmcloud-store-panel",
# IMPORTANT: bump v to avoid caching old JS # 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_title="Bahmcloud Store",
sidebar_icon="mdi:store", sidebar_icon="mdi:store",
require_admin=True, require_admin=True,

View File

@@ -1172,13 +1172,17 @@ class BCSCore:
"latest_version": r.latest_version, "latest_version": r.latest_version,
"latest_version_source": r.latest_version_source, "latest_version_source": r.latest_version_source,
"category": r.meta_category, "category": r.meta_category,
"category_key": self._repo_install_type(r),
"install_target": self._repo_install_target(r),
"meta_author": r.meta_author, "meta_author": r.meta_author,
"meta_maintainer": r.meta_maintainer, "meta_maintainer": r.meta_maintainer,
"meta_source": r.meta_source, "meta_source": r.meta_source,
"installed": installed, "installed": installed,
"install_type": str(inst.get("install_type") if isinstance(inst, dict) else self._repo_install_type(r)),
"installed_version": installed_version, "installed_version": installed_version,
"installed_manifest_version": installed_manifest_version, "installed_manifest_version": installed_manifest_version,
"installed_domains": installed_domains, "installed_domains": installed_domains,
"installed_paths": list(inst.get("installed_paths") or []) if isinstance(inst, dict) else [],
"favorite": self.is_favorite_repo(r.id), "favorite": self.is_favorite_repo(r.id),
} }
) )
@@ -1220,6 +1224,32 @@ class BCSCore:
return str(repo.default_branch).strip() return str(repo.default_branch).strip()
return "main" 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: def _build_zip_url(self, repo_url: str, ref: str) -> str:
ref = (ref or "").strip() ref = (ref or "").strip()
if not ref: if not ref:
@@ -1286,6 +1316,18 @@ class BCSCore:
return candidate return candidate
return None 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: async def _ensure_backup_root(self) -> None:
"""Create backup root directory if needed.""" """Create backup root directory if needed."""
def _mkdir() -> None: def _mkdir() -> None:
@@ -1640,15 +1682,25 @@ class BCSCore:
for it in items: for it in items:
domains = [str(d) for d in (it.domains or []) if str(d).strip()] 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 # A repo is considered "present" if at least one of its domains
# exists and contains a manifest.json. # exists and contains a manifest.json.
present = False present = False
for d in domains: if install_type == "integration":
p = cc_root / d for d in domains:
if p.is_dir() and (p / "manifest.json").exists(): p = cc_root / d
present = True if p.is_dir() and (p / "manifest.json").exists():
break 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: if not present:
to_remove.append(it.repo_id) to_remove.append(it.repo_id)
@@ -1657,6 +1709,8 @@ class BCSCore:
cache[it.repo_id] = { cache[it.repo_id] = {
"installed": True, "installed": True,
"domains": domains, "domains": domains,
"install_type": install_type,
"installed_paths": installed_paths,
"installed_version": it.installed_version, "installed_version": it.installed_version,
"installed_manifest_version": it.installed_manifest_version, "installed_manifest_version": it.installed_manifest_version,
"ref": it.ref, "ref": it.ref,
@@ -1692,24 +1746,50 @@ class BCSCore:
if path.exists() and path.is_dir(): if path.exists() and path.is_dir():
shutil.rmtree(path, ignore_errors=True) shutil.rmtree(path, ignore_errors=True)
for domain in inst.domains: def _remove_file(path: Path) -> None:
d = str(domain).strip() if path.exists() and path.is_file():
if not d: path.unlink(missing_ok=True)
continue
target = cc_root / d def _prune_empty_parents(path: Path, stop_at: Path) -> None:
await self.hass.async_add_executor_job(_remove_dir, target) cur = path.parent
removed.append(d) 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.storage.remove_installed_repo(repo_id)
await self._refresh_installed_cache() await self._refresh_installed_cache()
# Show restart required in Settings. # Show restart required in Settings.
if removed: if removed and install_type == "integration":
self._mark_restart_required() self._mark_restart_required()
_LOGGER.info("BCS uninstall complete: repo_id=%s removed_domains=%s", repo_id, removed) _LOGGER.info("BCS uninstall complete: repo_id=%s removed_domains=%s", repo_id, removed)
self.signal_updated() 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]: async def install_repo(self, repo_id: str, *, version: str | None = None) -> dict[str, Any]:
repo = self.get_repo(repo_id) 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) _LOGGER.info("BCS install started: repo_id=%s ref=%s zip_url=%s", repo_id, ref, zip_url)
installed_domains: list[str] = [] installed_domains: list[str] = []
installed_paths: list[str] = []
backups: dict[str, Path] = {} backups: dict[str, Path] = {}
install_type = self._repo_install_type(repo)
inst_before = self.get_installed(repo_id) or {} inst_before = self.get_installed(repo_id) or {}
backup_meta = { backup_meta = {
@@ -1745,57 +1827,85 @@ class BCSCore:
await self._download_zip(zip_url, zip_path) await self._download_zip(zip_url, zip_path)
await self._extract_zip(zip_path, extract_dir) await self._extract_zip(zip_path, extract_dir)
cc_root = self._find_custom_components_root(extract_dir) if install_type == "blueprint":
if not cc_root: blueprints_root = self._find_blueprints_root(extract_dir)
raise BCSInstallError("custom_components folder not found in repository ZIP") 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(): def _copy_blueprints() -> list[str]:
if not domain_dir.is_dir(): copied: list[str] = []
continue target_root.mkdir(parents=True, exist_ok=True)
manifest = domain_dir / "manifest.json" for src in blueprints_root.rglob("*"):
if not manifest.exists(): if not src.is_file():
continue 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 installed_paths = await self.hass.async_add_executor_job(_copy_blueprints)
target = dest_root / domain 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. dest_root = Path(self.hass.config.path("custom_components"))
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) for domain_dir in cc_root.iterdir():
installed_domains.append(domain) if not domain_dir.is_dir():
continue
manifest = domain_dir / "manifest.json"
if not manifest.exists():
continue
if not installed_domains: domain = domain_dir.name
raise BCSInstallError("No integrations found under custom_components/ (missing manifest.json)") 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 installed_version = ref
await self.storage.set_installed_repo( await self.storage.set_installed_repo(
repo_id=repo_id, repo_id=repo_id,
url=repo.url, url=repo.url,
domains=installed_domains, domains=installed_domains,
install_type=install_type,
installed_paths=installed_paths,
installed_version=installed_version, installed_version=installed_version,
installed_manifest_version=installed_manifest_version, installed_manifest_version=installed_manifest_version,
ref=ref, ref=ref,
) )
await self._refresh_installed_cache() await self._refresh_installed_cache()
self._mark_restart_required() if install_type == "integration":
self._mark_restart_required()
_LOGGER.info( _LOGGER.info(
"BCS install complete: repo_id=%s domains=%s installed_ref=%s manifest_version=%s", "BCS install complete: repo_id=%s domains=%s installed_ref=%s manifest_version=%s",
repo_id, repo_id,
installed_domains, installed_domains if installed_domains else installed_paths,
installed_version, installed_version,
installed_manifest_version, installed_manifest_version,
) )
@@ -1804,9 +1914,11 @@ class BCSCore:
"ok": True, "ok": True,
"repo_id": repo_id, "repo_id": repo_id,
"domains": installed_domains, "domains": installed_domains,
"installed_paths": installed_paths,
"install_type": install_type,
"installed_version": installed_version, "installed_version": installed_version,
"installed_manifest_version": installed_manifest_version, "installed_manifest_version": installed_manifest_version,
"restart_required": True, "restart_required": True if install_type == "integration" else False,
} }
except Exception as e: 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?.latest_version) ? `Latest: ${this._safeText(r?.latest_version)}` : "Latest: -",
this._safeText(r?.provider) ? `Provider: ${this._safeText(r?.provider)}` : null, this._safeText(r?.provider) ? `Provider: ${this._safeText(r?.provider)}` : null,
this._safeText(r?.category) ? `Category: ${this._safeText(r?.category)}` : 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_author) ? `Author: ${this._safeText(r?.meta_author)}` : null,
this._safeText(r?.meta_maintainer) ? `Maintainer: ${this._safeText(r?.meta_maintainer)}` : 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, 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 installed = this._asBoolStrict(r?.installed);
const installedVersion = this._safeText(r?.installed_version); const installedVersion = this._safeText(r?.installed_version);
const installedDomains = Array.isArray(r?.installed_domains) ? r.installed_domains : []; 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 latestVersion = this._safeText(r?.latest_version);
const favorite = this._asBoolStrict(r?.favorite) || this._isFavoriteRepo(repoId); const favorite = this._asBoolStrict(r?.favorite) || this._isFavoriteRepo(repoId);
const installType = this._safeText(r?.install_type) || "integration";
const busyInstall = this._installingRepoId === repoId; const busyInstall = this._installingRepoId === repoId;
const busyUpdate = this._updatingRepoId === 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 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 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 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 favoriteBtn = `<button id="btnFavorite">${favorite ? "Unpin" : "Pin"}</button>`;
const restartHint = this._restartRequired const restartHint = this._restartRequired
@@ -1424,6 +1429,7 @@ class BahmcloudStorePanel extends HTMLElement {
<div><strong>Installed version:</strong> ${this._esc(installedVersion || "-")}</div> <div><strong>Installed version:</strong> ${this._esc(installedVersion || "-")}</div>
<div><strong>Latest version:</strong> ${this._esc(latestVersion || "-")}</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>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> </div>
${versionSelect} ${versionSelect}

View File

@@ -25,6 +25,8 @@ class InstalledRepo:
url: str url: str
domains: list[str] domains: list[str]
installed_at: int 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_version: str | None = None # BCS "installed ref" (tag/release/branch)
installed_manifest_version: str | None = None # informational only installed_manifest_version: str | None = None # informational only
ref: str | None = None # kept for backward compatibility / diagnostics ref: str | None = None # kept for backward compatibility / diagnostics
@@ -197,6 +199,11 @@ class BCSStorage:
if not isinstance(domains, list): if not isinstance(domains, list):
domains = [] domains = []
domains = [str(d) for d in domains if str(d).strip()] 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") installed_version = entry.get("installed_version")
ref = entry.get("ref") ref = entry.get("ref")
@@ -213,6 +220,8 @@ class BCSStorage:
url=str(entry.get("url") or ""), url=str(entry.get("url") or ""),
domains=domains, domains=domains,
installed_at=int(entry.get("installed_at") or 0), 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_version=str(installed_version) if installed_version else None,
installed_manifest_version=str(installed_manifest_version) if installed_manifest_version else None, installed_manifest_version=str(installed_manifest_version) if installed_manifest_version else None,
ref=str(ref) if ref else None, ref=str(ref) if ref else None,
@@ -238,9 +247,11 @@ class BCSStorage:
repo_id: str, repo_id: str,
url: str, url: str,
domains: list[str], domains: list[str],
installed_version: str | None, installed_version: str | None = None,
installed_manifest_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: ) -> None:
data = await self._load() data = await self._load()
installed = data.get("installed_repos", {}) installed = data.get("installed_repos", {})
@@ -252,6 +263,8 @@ class BCSStorage:
"repo_id": str(repo_id), "repo_id": str(repo_id),
"url": str(url), "url": str(url),
"domains": [str(d) for d in (domains or []) if str(d).strip()], "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()), "installed_at": int(time.time()),
# IMPORTANT: this is what BCS uses as "installed version" (ref/tag/branch) # IMPORTANT: this is what BCS uses as "installed version" (ref/tag/branch)
"installed_version": installed_version, "installed_version": installed_version,
@@ -268,4 +281,4 @@ class BCSStorage:
if isinstance(installed, dict) and repo_id in installed: if isinstance(installed, dict) and repo_id in installed:
installed.pop(repo_id, None) installed.pop(repo_id, None)
data["installed_repos"] = installed 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 [] domains = inst.get("domains") or []
if not isinstance(domains, list): if not isinstance(domains, list):
domains = [] domains = []
installed_paths = inst.get("installed_paths") or []
if not isinstance(installed_paths, list):
installed_paths = []
return web.json_response({ return web.json_response({
"ok": True, "ok": True,
@@ -556,13 +559,17 @@ class BCSRepoDetailView(HomeAssistantView):
"latest_version": repo.latest_version, "latest_version": repo.latest_version,
"latest_version_source": repo.latest_version_source, "latest_version_source": repo.latest_version_source,
"category": repo.meta_category, "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_author": repo.meta_author,
"meta_maintainer": repo.meta_maintainer, "meta_maintainer": repo.meta_maintainer,
"meta_source": repo.meta_source, "meta_source": repo.meta_source,
"installed": installed, "installed": installed,
"install_type": inst.get("install_type") or self.core._repo_install_type(repo),
"installed_version": inst.get("installed_version"), "installed_version": inst.get("installed_version"),
"installed_manifest_version": inst.get("installed_manifest_version"), "installed_manifest_version": inst.get("installed_manifest_version"),
"installed_domains": domains, "installed_domains": domains,
"installed_paths": installed_paths,
"favorite": self.core.is_favorite_repo(repo.id), "favorite": self.core.is_favorite_repo(repo.id),
} }
}, status=200) }, status=200)