7 Commits
0.6.1 ... 0.6.2

Author SHA1 Message Date
f1e03b31a1 add 0.6.2 2026-01-18 13:16:42 +00:00
4e12d596d6 0.6.2 2026-01-18 13:12:47 +00:00
fa97f89afb 0.6.2 2026-01-18 13:12:21 +00:00
0718bee185 0.6.2 2026-01-18 13:11:39 +00:00
1a53107450 0.6.2 2026-01-18 13:10:59 +00:00
ab82cc6fd3 0.6.2 2026-01-18 13:10:16 +00:00
8e51f144e1 0.6.2 2026-01-18 13:09:37 +00:00
7 changed files with 308 additions and 14 deletions

View File

@@ -11,6 +11,12 @@ Sections:
---
## [0.6.2] - 2026-01-18
### Added
- Selectable install/update version per repository (install older releases/tags to downgrade when needed).
- New API endpoint to list available versions for a repository: `GET /api/bcs/versions?repo_id=...`.
## [0.6.1] - 2026-01-18
### Fixed

View File

@@ -35,6 +35,7 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
StaticAssetsView,
BCSApiView,
BCSReadmeView,
BCSVersionsView,
BCSCustomRepoView,
BCSInstallView,
BCSUpdateView,
@@ -47,6 +48,7 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
hass.http.register_view(StaticAssetsView())
hass.http.register_view(BCSApiView(core))
hass.http.register_view(BCSReadmeView(core))
hass.http.register_view(BCSVersionsView(core))
hass.http.register_view(BCSCustomRepoView(core))
hass.http.register_view(BCSInstallView(core))
hass.http.register_view(BCSUpdateView(core))
@@ -60,7 +62,7 @@ async def async_setup(hass: HomeAssistant, config: dict) -> 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=103",
module_url="/api/bahmcloud_store_static/panel.js?v=105",
sidebar_title="Bahmcloud Store",
sidebar_icon="mdi:store",
require_admin=True,

View File

@@ -20,7 +20,7 @@ from homeassistant.helpers import issue_registry as ir
from homeassistant.util import yaml as ha_yaml
from .storage import BCSStorage, CustomRepo
from .providers import fetch_repo_info, detect_provider, RepoInfo, fetch_readme_markdown
from .providers import fetch_repo_info, detect_provider, RepoInfo, fetch_readme_markdown, fetch_repo_versions
from .metadata import fetch_repo_metadata, RepoMetadata
_LOGGER = logging.getLogger(__name__)
@@ -415,6 +415,23 @@ class BCSCore:
default_branch=repo.default_branch,
)
async def list_repo_versions(self, repo_id: str, *, limit: int = 20) -> list[dict[str, str]]:
"""List installable versions/refs for a repo.
This is used by the UI to allow selecting an older tag/release.
"""
repo = self.get_repo(repo_id)
if not repo:
raise BCSInstallError(f"repo_id not found: {repo_id}")
return await fetch_repo_versions(
self.hass,
repo.url,
provider=repo.provider,
default_branch=repo.default_branch,
limit=limit,
)
def _pick_ref_for_install(self, repo: RepoItem) -> str:
if repo.latest_version and str(repo.latest_version).strip():
return str(repo.latest_version).strip()
@@ -913,13 +930,14 @@ class BCSCore:
self.signal_updated()
return {"ok": True, "repo_id": repo_id, "removed": removed, "restart_required": bool(removed)}
async def install_repo(self, repo_id: str) -> dict[str, Any]:
async def install_repo(self, repo_id: str, *, version: str | None = None) -> dict[str, Any]:
repo = self.get_repo(repo_id)
if not repo:
raise BCSInstallError(f"repo_id not found: {repo_id}")
async with self._install_lock:
ref = self._pick_ref_for_install(repo)
requested = (version or "").strip()
ref = requested if requested else self._pick_ref_for_install(repo)
zip_url = self._build_zip_url(repo.url, ref)
_LOGGER.info("BCS install started: repo_id=%s ref=%s zip_url=%s", repo_id, ref, zip_url)
@@ -1039,9 +1057,9 @@ class BCSCore:
raise
raise BCSInstallError(str(e)) from e
async def update_repo(self, repo_id: str) -> dict[str, Any]:
async def update_repo(self, repo_id: str, *, version: str | None = None) -> dict[str, Any]:
_LOGGER.info("BCS update started: repo_id=%s", repo_id)
return await self.install_repo(repo_id)
return await self.install_repo(repo_id, version=version)
async def request_restart(self) -> None:
await self.hass.services.async_call("homeassistant", "restart", {}, blocking=False)

View File

@@ -1,7 +1,7 @@
{
"domain": "bahmcloud_store",
"name": "Bahmcloud Store",
"version": "0.6.1",
"version": "0.6.2",
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
"platforms": ["update"],
"requirements": [],

View File

@@ -45,6 +45,11 @@ class BahmcloudStorePanel extends HTMLElement {
this._restoreSelected = "";
this._restoring = false;
this._restoreError = null;
// Phase C1: selectable install version
this._versionsCache = {}; // repo_id -> [{ref,label,source}, ...]
this._versionsLoadingRepoId = null;
this._selectedVersionByRepoId = {}; // repo_id -> ref ("" means latest)
}
set hass(hass) {
@@ -114,7 +119,13 @@ class BahmcloudStorePanel extends HTMLElement {
this._update();
try {
const resp = await this._hass.callApi("post", `bcs/install?repo_id=${encodeURIComponent(repoId)}`, {});
const sel = this._safeText(this._selectedVersionByRepoId?.[repoId] || "").trim();
const qv = sel ? `&version=${encodeURIComponent(sel)}` : "";
const resp = await this._hass.callApi(
"post",
`bcs/install?repo_id=${encodeURIComponent(repoId)}${qv}`,
{},
);
if (!resp?.ok) {
this._error = this._safeText(resp?.message) || "Install failed.";
} else {
@@ -140,7 +151,13 @@ class BahmcloudStorePanel extends HTMLElement {
this._update();
try {
const resp = await this._hass.callApi("post", `bcs/update?repo_id=${encodeURIComponent(repoId)}`, {});
const sel = this._safeText(this._selectedVersionByRepoId?.[repoId] || "").trim();
const qv = sel ? `&version=${encodeURIComponent(sel)}` : "";
const resp = await this._hass.callApi(
"post",
`bcs/update?repo_id=${encodeURIComponent(repoId)}${qv}`,
{},
);
if (!resp?.ok) {
this._error = this._safeText(resp?.message) || "Update failed.";
} else {
@@ -366,8 +383,41 @@ class BahmcloudStorePanel extends HTMLElement {
this._readmeExpanded = false;
this._readmeCanToggle = false;
// Versions dropdown
if (!(repoId in this._selectedVersionByRepoId)) {
this._selectedVersionByRepoId[repoId] = ""; // default = latest
}
this._update();
this._loadReadme(repoId);
this._loadVersions(repoId);
}
async _loadVersions(repoId) {
if (!this._hass) return;
if (!repoId) return;
// Cache: avoid re-fetching repeatedly in the same session.
if (Array.isArray(this._versionsCache?.[repoId]) && this._versionsCache[repoId].length) {
return;
}
this._versionsLoadingRepoId = repoId;
this._update();
try {
const resp = await this._hass.callApi("get", `bcs/versions?repo_id=${encodeURIComponent(repoId)}`);
if (resp?.ok && Array.isArray(resp.versions)) {
this._versionsCache[repoId] = resp.versions;
} else {
this._versionsCache[repoId] = [];
}
} catch (e) {
this._versionsCache[repoId] = [];
} finally {
this._versionsLoadingRepoId = null;
this._update();
}
}
async _loadReadme(repoId) {
@@ -953,6 +1003,8 @@ class BahmcloudStorePanel extends HTMLElement {
const r = this._detailRepo;
if (!r) return `<div class="card">No repository selected.</div>`;
const repoId = this._safeId(r?.id) || this._detailRepoId || "";
const name = this._safeText(r?.name) || "Unnamed repository";
const url = this._safeText(r?.url) || "";
const desc = this._safeText(r?.description) || "";
@@ -1001,8 +1053,6 @@ class BahmcloudStorePanel extends HTMLElement {
</div>
`;
const repoId = this._safeId(r?.id);
const installed = this._asBoolStrict(r?.installed);
const installedVersion = this._safeText(r?.installed_version);
const installedDomains = Array.isArray(r?.installed_domains) ? r.installed_domains : [];
@@ -1015,6 +1065,32 @@ class BahmcloudStorePanel extends HTMLElement {
const updateAvailable = installed && !!latestVersion && (!installedVersion || latestVersion !== installedVersion);
const versions = Array.isArray(this._versionsCache?.[repoId]) ? this._versionsCache[repoId] : [];
const versionsLoading = this._versionsLoadingRepoId === repoId;
const selectedRef = this._safeText(this._selectedVersionByRepoId?.[repoId] || "").trim();
let versionOptions = `<option value="">Latest (recommended)</option>`;
if (selectedRef && !versions.some((v) => this._safeText(v?.ref) === selectedRef)) {
versionOptions += `<option value="${this._esc(selectedRef)}" selected>Selected: ${this._esc(selectedRef)}</option>`;
}
for (const v of versions) {
const ref = this._safeText(v?.ref);
if (!ref) continue;
const label = this._safeText(v?.label) || ref;
const sel = selectedRef === ref ? "selected" : "";
versionOptions += `<option value="${this._esc(ref)}" ${sel}>${this._esc(label)}</option>`;
}
const versionSelect = `
<div style="margin-top:12px;">
<div class="muted small" style="margin-bottom:6px;"><strong>Install version:</strong></div>
<select id="selVersion" ${busy ? "disabled" : ""} style="width:100%;">
${versionOptions}
</select>
${versionsLoading ? `<div class="muted small" style="margin-top:6px;">Loading versions…</div>` : ``}
</div>
`;
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>`;
@@ -1067,6 +1143,8 @@ class BahmcloudStorePanel extends HTMLElement {
<div style="margin-top:6px;"><strong>Domains:</strong> ${installedDomains.length ? this._esc(installedDomains.join(", ")) : "-"}</div>
</div>
${versionSelect}
<div class="row" style="margin-top:14px; gap:10px; flex-wrap:wrap;">
${installBtn}
${updateBtn}
@@ -1091,6 +1169,7 @@ class BahmcloudStorePanel extends HTMLElement {
const btnRestore = root.getElementById("btnRestore");
const btnRestart = root.getElementById("btnRestart");
const btnReadmeToggle = root.getElementById("btnReadmeToggle");
const selVersion = root.getElementById("selVersion");
if (btnInstall) {
btnInstall.addEventListener("click", () => {
@@ -1099,6 +1178,14 @@ class BahmcloudStorePanel extends HTMLElement {
});
}
if (selVersion) {
selVersion.addEventListener("change", () => {
if (!this._detailRepoId) return;
const v = selVersion.value != null ? String(selVersion.value) : "";
this._selectedVersionByRepoId[this._detailRepoId] = v;
});
}
if (btnUpdate) {
btnUpdate.addEventListener("click", () => {
if (btnUpdate.disabled) return;

View File

@@ -504,4 +504,160 @@ async def fetch_readme_markdown(
except Exception:
continue
return None
return None
async def fetch_repo_versions(
hass: HomeAssistant,
repo_url: str,
*,
provider: str | None = None,
default_branch: str | None = None,
limit: int = 20,
) -> list[dict[str, str]]:
"""List available versions/refs for a repository.
Returns a list of dicts with keys:
- ref: the ref to install (tag/release/branch)
- label: human-friendly label
- source: release|tag|branch
Notes:
- Uses public endpoints (no tokens) for public repositories.
- We prefer releases first (if available), then tags.
"""
repo_url = (repo_url or "").strip()
if not repo_url:
return []
prov = (provider or "").strip().lower() if provider else ""
if not prov:
prov = detect_provider(repo_url)
owner, repo = _split_owner_repo(repo_url)
if not owner or not repo:
return []
session = async_get_clientsession(hass)
headers = {"User-Agent": UA}
out: list[dict[str, str]] = []
seen: set[str] = set()
def _add(ref: str | None, label: str, source: str) -> None:
r = (ref or "").strip()
if not r or r in seen:
return
seen.add(r)
out.append({"ref": r, "label": label, "source": source})
# Always offer default branch as an explicit option.
if default_branch and str(default_branch).strip():
b = str(default_branch).strip()
_add(b, f"Branch: {b}", "branch")
try:
if prov == "github":
# Releases
gh_headers = {"Accept": "application/vnd.github+json", "User-Agent": UA}
data, _ = await _safe_json(
session,
f"https://api.github.com/repos/{owner}/{repo}/releases?per_page={int(limit)}",
headers=gh_headers,
)
if isinstance(data, list):
for r in data:
if not isinstance(r, dict):
continue
tag = r.get("tag_name")
name = r.get("name")
if tag:
lbl = str(tag)
if isinstance(name, str) and name.strip() and name.strip() != str(tag):
lbl = f"{tag}{name.strip()}"
_add(str(tag), lbl, "release")
# Tags
data, _ = await _safe_json(
session,
f"https://api.github.com/repos/{owner}/{repo}/tags?per_page={int(limit)}",
headers=gh_headers,
)
if isinstance(data, list):
for t in data:
if isinstance(t, dict) and t.get("name"):
_add(str(t["name"]), str(t["name"]), "tag")
return out
if prov == "gitlab":
u = urlparse(repo_url.rstrip("/"))
base = f"{u.scheme}://{u.netloc}"
project = quote_plus(f"{owner}/{repo}")
data, _ = await _safe_json(
session,
f"{base}/api/v4/projects/{project}/releases?per_page={int(limit)}",
headers=headers,
)
if isinstance(data, list):
for r in data:
if not isinstance(r, dict):
continue
tag = r.get("tag_name")
name = r.get("name")
if tag:
lbl = str(tag)
if isinstance(name, str) and name.strip() and name.strip() != str(tag):
lbl = f"{tag}{name.strip()}"
_add(str(tag), lbl, "release")
data, _ = await _safe_json(
session,
f"{base}/api/v4/projects/{project}/repository/tags?per_page={int(limit)}",
headers=headers,
)
if isinstance(data, list):
for t in data:
if isinstance(t, dict) and t.get("name"):
_add(str(t["name"]), str(t["name"]), "tag")
return out
# gitea (incl. Bahmcloud)
u = urlparse(repo_url.rstrip("/"))
base = f"{u.scheme}://{u.netloc}"
data, _ = await _safe_json(
session,
f"{base}/api/v1/repos/{owner}/{repo}/releases?limit={int(limit)}",
headers=headers,
)
if isinstance(data, list):
for r in data:
if not isinstance(r, dict):
continue
tag = r.get("tag_name")
name = r.get("name")
if tag:
lbl = str(tag)
if isinstance(name, str) and name.strip() and name.strip() != str(tag):
lbl = f"{tag}{name.strip()}"
_add(str(tag), lbl, "release")
data, _ = await _safe_json(
session,
f"{base}/api/v1/repos/{owner}/{repo}/tags?limit={int(limit)}",
headers=headers,
)
if isinstance(data, list):
for t in data:
if isinstance(t, dict) and t.get("name"):
_add(str(t["name"]), str(t["name"]), "tag")
return out
except Exception:
_LOGGER.debug("fetch_repo_versions failed for %s", repo_url, exc_info=True)
return out

View File

@@ -292,6 +292,27 @@ class BCSReadmeView(HomeAssistantView):
return web.json_response({"ok": True, "readme": md_str, "html": html})
class BCSVersionsView(HomeAssistantView):
url = "/api/bcs/versions"
name = "api:bcs_versions"
requires_auth = True
def __init__(self, core: Any) -> None:
self.core: BCSCore = core
async def get(self, request: web.Request) -> web.Response:
repo_id = request.query.get("repo_id")
if not repo_id:
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
try:
versions = await self.core.list_repo_versions(repo_id)
return web.json_response({"ok": True, "repo_id": repo_id, "versions": versions}, status=200)
except Exception as e:
_LOGGER.exception("BCS list versions failed: %s", e)
return web.json_response({"ok": False, "message": str(e) or "List versions failed"}, status=500)
class BCSInstallView(HomeAssistantView):
url = "/api/bcs/install"
name = "api:bcs_install"
@@ -302,11 +323,13 @@ class BCSInstallView(HomeAssistantView):
async def post(self, request: web.Request) -> web.Response:
repo_id = request.query.get("repo_id")
version = request.query.get("version")
if not repo_id:
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
try:
result = await self.core.install_repo(repo_id)
v = str(version).strip() if version is not None else None
result = await self.core.install_repo(repo_id, version=v)
return web.json_response(result, status=200)
except Exception as e:
_LOGGER.exception("BCS install failed: %s", e)
@@ -323,11 +346,13 @@ class BCSUpdateView(HomeAssistantView):
async def post(self, request: web.Request) -> web.Response:
repo_id = request.query.get("repo_id")
version = request.query.get("version")
if not repo_id:
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
try:
result = await self.core.update_repo(repo_id)
v = str(version).strip() if version is not None else None
result = await self.core.update_repo(repo_id, version=v)
return web.json_response(result, status=200)
except Exception as e:
_LOGGER.exception("BCS update failed: %s", e)