16 Commits
0.4.1 ... 0.5.0

Author SHA1 Message Date
d78217100c custom_components/bahmcloud_store/manifest.json aktualisiert 2026-01-15 17:53:28 +00:00
09e1ef1af5 CHANGELOG.md aktualisiert 2026-01-15 17:53:13 +00:00
9ad558c9ab custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 17:42:38 +00:00
19df0eea22 custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 17:41:58 +00:00
745979b9a6 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 17:19:45 +00:00
f861b2490a custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 17:18:45 +00:00
32946c1a98 custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 17:17:48 +00:00
a9a681d801 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 17:06:43 +00:00
2ae6ac43a5 custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 17:01:41 +00:00
504c126c2c custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 16:59:33 +00:00
85cc97b557 custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 16:58:31 +00:00
4ca80a9c88 custom_components/bahmcloud_store/panel/app.js aktualisiert 2026-01-15 16:45:23 +00:00
ac5bc8a6f4 custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 16:21:14 +00:00
c4361cc8bd custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 16:13:42 +00:00
1794d579d2 custom_components/bahmcloud_store/panel/app.js aktualisiert 2026-01-15 16:08:48 +00:00
bcfbf7151c custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 16:04:59 +00:00
7 changed files with 1185 additions and 161 deletions

View File

@@ -11,6 +11,21 @@ Sections:
---
## [0.5.0] - 2026-01-15
### Added
- Manual refresh button that triggers a full backend refresh (store index + provider data).
- Unified refresh pipeline: startup, timer and UI now use the same refresh logic.
- Cache-busting for store index requests to always fetch the latest store.yaml.
### Improved
- Logging for store index loading and parsing.
- Refresh behavior now deterministic and verifiable via logs.
### Fixed
- Refresh button previously only reloaded cached data.
- Store index was not always reloaded immediately on user action.
## [0.4.1] - 2026-01-15
### Fixed
- Fixed GitLab README loading by using robust raw file endpoints.

View File

@@ -4,10 +4,8 @@ import logging
from datetime import timedelta
from homeassistant.core import HomeAssistant
from homeassistant.const import Platform
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.components.panel_custom import async_register_panel
from homeassistant.helpers.event import async_track_time_interval
from .core import BCSCore, BCSConfig, BCSError
@@ -20,40 +18,49 @@ CONF_STORE_URL = "store_url"
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
cfg = config.get(DOMAIN, {})
cfg = config.get(DOMAIN, {}) or {}
store_url = cfg.get(CONF_STORE_URL, DEFAULT_STORE_URL)
core = BCSCore(hass, BCSConfig(store_url=store_url))
hass.data[DOMAIN] = core
await core.register_http_views()
# Avoid blocking IO during setup
await core.async_initialize()
# Register HTTP views and panel
from .views import StaticAssetsView, BCSApiView, BCSReadmeView, BCSCustomRepoView
hass.http.register_view(StaticAssetsView())
hass.http.register_view(BCSApiView(core))
hass.http.register_view(BCSReadmeView(core))
hass.http.register_view(BCSCustomRepoView(core))
# RESTORE: keep the module_url pattern that worked for you
await async_register_panel(
hass,
frontend_url_path="bahmcloud-store",
webcomponent_name="bahmcloud-store-panel",
module_url="/api/bahmcloud_store_static/panel.js?v=42",
module_url="/api/bahmcloud_store_static/panel.js?v=99",
sidebar_title="Bahmcloud Store",
sidebar_icon="mdi:store",
require_admin=True,
config={},
)
# Initial refresh
try:
await core.refresh()
await core.full_refresh(source="startup")
except BCSError as e:
_LOGGER.error("Initial refresh failed: %s", e)
async def periodic(_now) -> None:
try:
await core.refresh()
core.signal_updated()
await core.full_refresh(source="timer")
except BCSError as e:
_LOGGER.warning("Periodic refresh failed: %s", e)
except Exception as e:
_LOGGER.exception("Unexpected error during periodic refresh: %s", e)
interval = timedelta(seconds=int(core.refresh_seconds or 300))
async_track_time_interval(hass, periodic, interval)
interval_seconds = int(getattr(core, "refresh_seconds", 300) or 300)
async_track_time_interval(hass, periodic, timedelta(seconds=interval_seconds))
await async_load_platform(hass, Platform.UPDATE, DOMAIN, {}, config)
return True

View File

@@ -1,20 +1,20 @@
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util import yaml as ha_yaml
from .storage import BCSStorage, CustomRepo
from .views import StaticAssetsView, BCSApiView, BCSReadmeView
from .custom_repo_view import BCSCustomRepoView
from .providers import fetch_repo_info, detect_provider, RepoInfo, fetch_readme_markdown
from .metadata import fetch_repo_metadata, RepoMetadata
@@ -66,16 +66,30 @@ class BCSCore:
self.repos: dict[str, RepoItem] = {}
self._listeners: list[callable] = []
self.version: str = self._read_manifest_version()
# Will be loaded asynchronously (no blocking IO in event loop)
self.version: str = "unknown"
def _read_manifest_version(self) -> str:
try:
manifest_path = Path(__file__).resolve().parent / "manifest.json"
data = json.loads(manifest_path.read_text(encoding="utf-8"))
v = data.get("version")
return str(v) if v else "unknown"
except Exception:
return "unknown"
# Diagnostics (helps verify refresh behavior)
self.last_index_url: str | None = None
self.last_index_bytes: int | None = None
self.last_index_hash: str | None = None
self.last_index_loaded_at: float | None = None
async def async_initialize(self) -> None:
"""Async initialization that avoids blocking file IO."""
self.version = await self._read_manifest_version_async()
async def _read_manifest_version_async(self) -> str:
def _read() -> str:
try:
manifest_path = Path(__file__).resolve().parent / "manifest.json"
data = json.loads(manifest_path.read_text(encoding="utf-8"))
v = data.get("version")
return str(v) if v else "unknown"
except Exception:
return "unknown"
return await self.hass.async_add_executor_job(_read)
def add_listener(self, cb) -> None:
self._listeners.append(cb)
@@ -87,11 +101,11 @@ class BCSCore:
except Exception:
pass
async def register_http_views(self) -> None:
self.hass.http.register_view(StaticAssetsView())
self.hass.http.register_view(BCSApiView(self))
self.hass.http.register_view(BCSReadmeView(self))
self.hass.http.register_view(BCSCustomRepoView(self))
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)
await self.refresh()
self.signal_updated()
def get_repo(self, repo_id: str) -> RepoItem | None:
return self.repos.get(repo_id)
@@ -121,6 +135,13 @@ class BCSCore:
await self._enrich_and_resolve(merged)
self.repos = merged
_LOGGER.info(
"BCS refresh complete: repos=%s (index=%s, custom=%s)",
len(self.repos),
len([r for r in self.repos.values() if r.source == "index"]),
len([r for r in self.repos.values() if r.source == "custom"]),
)
async def _enrich_and_resolve(self, merged: dict[str, RepoItem]) -> None:
sem = asyncio.Semaphore(6)
@@ -155,16 +176,72 @@ class BCSCore:
await asyncio.gather(*(process_one(r) for r in merged.values()), return_exceptions=True)
async def _load_index_repos(self) -> tuple[list[RepoItem], int]:
def _add_cache_buster(self, url: str) -> str:
parts = urlsplit(url)
q = dict(parse_qsl(parts.query, keep_blank_values=True))
q["t"] = str(int(time.time()))
new_query = urlencode(q)
return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment))
def _gitea_src_to_raw(self, url: str) -> str:
parts = urlsplit(url)
path = parts.path
path2 = path.replace("/src/branch/", "/raw/branch/")
if path2 == path:
return url
return urlunsplit((parts.scheme, parts.netloc, path2, parts.query, parts.fragment))
async def _fetch_store_text(self, url: str) -> str:
session = async_get_clientsession(self.hass)
headers = {
"User-Agent": "BahmcloudStore (Home Assistant)",
"Cache-Control": "no-cache, no-store, max-age=0",
"Pragma": "no-cache",
"Expires": "0",
}
async with session.get(url, timeout=30, headers=headers) as resp:
if resp.status != 200:
raise BCSError(f"store_url returned {resp.status}")
return await resp.text()
async def _load_index_repos(self) -> tuple[list[RepoItem], int]:
store_url = (self.config.store_url or "").strip()
if not store_url:
raise BCSError("store_url is empty")
url = self._add_cache_buster(store_url)
try:
async with session.get(self.config.store_url, timeout=20) as resp:
if resp.status != 200:
raise BCSError(f"store_url returned {resp.status}")
raw = await resp.text()
raw = await self._fetch_store_text(url)
# If we fetched a HTML page (wrong endpoint), attempt raw conversion.
if "<html" in raw.lower() or "<!doctype html" in raw.lower():
fallback = self._add_cache_buster(self._gitea_src_to_raw(store_url))
if fallback != url:
_LOGGER.warning("BCS store index looked like HTML, retrying raw URL")
raw = await self._fetch_store_text(fallback)
url = fallback
except Exception as e:
raise BCSError(f"Failed fetching store index: {e}") from e
# Diagnostics
b = raw.encode("utf-8", errors="replace")
h = hashlib.sha256(b).hexdigest()[:12]
self.last_index_url = url
self.last_index_bytes = len(b)
self.last_index_hash = h
self.last_index_loaded_at = time.time()
_LOGGER.info(
"BCS index loaded: url=%s bytes=%s sha=%s",
self.last_index_url,
self.last_index_bytes,
self.last_index_hash,
)
try:
data = ha_yaml.parse_yaml(raw)
if not isinstance(data, dict):
@@ -179,20 +256,21 @@ class BCSCore:
for i, r in enumerate(repos):
if not isinstance(r, dict):
continue
url = str(r.get("url", "")).strip()
if not url:
repo_url = str(r.get("url", "")).strip()
if not repo_url:
continue
name = str(r.get("name") or url).strip()
name = str(r.get("name") or repo_url).strip()
items.append(
RepoItem(
id=f"index:{i}",
name=name,
url=url,
url=repo_url,
source="index",
)
)
_LOGGER.info("BCS index parsed: repos=%s refresh_seconds=%s", len(items), refresh_seconds)
return items, refresh_seconds
except Exception as e:
raise BCSError(f"Invalid store.yaml: {e}") from e
@@ -203,14 +281,12 @@ class BCSCore:
raise BCSError("Missing url")
c = await self.storage.add_custom_repo(url, name)
await self.refresh()
self.signal_updated()
await self.full_refresh(source="custom_repo_add")
return c
async def remove_custom_repo(self, repo_id: str) -> None:
await self.storage.remove_custom_repo(repo_id)
await self.refresh()
self.signal_updated()
await self.full_refresh(source="custom_repo_remove")
async def list_custom_repos(self) -> list[CustomRepo]:
return await self.storage.list_custom_repos()

View File

@@ -1,7 +1,7 @@
{
"domain": "bahmcloud_store",
"name": "Bahmcloud Store",
"version": "0.4.1",
"version": "0.5.0",
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
"requirements": [],
"codeowners": ["@bahmcloud"],

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,10 @@ class BahmcloudStorePanel extends HTMLElement {
this._readmeText = null;
this._readmeHtml = null;
this._readmeError = null;
// Manual refresh UX state
this._refreshing = false;
this._status = ""; // short status line shown in UI
}
set hass(hass) {
@@ -53,6 +57,34 @@ class BahmcloudStorePanel extends HTMLElement {
}
}
async _refreshAll() {
if (!this._hass) return;
if (this._refreshing) return;
this._refreshing = true;
this._error = null;
this._status = "Refreshing…";
this._update();
try {
// This triggers: POST /api/bcs?action=refresh
const resp = await this._hass.callApi("post", "bcs?action=refresh", {});
if (!resp?.ok) {
this._status = "";
this._error = this._safeText(resp?.message) || "Refresh failed.";
} else {
this._status = "Refresh done.";
}
} catch (e) {
this._status = "";
this._error = e?.message ? String(e.message) : String(e);
} finally {
this._refreshing = false;
// Always reload data after refresh attempt
await this._load();
}
}
_isDesktop() {
return window.matchMedia && window.matchMedia("(min-width: 1024px)").matches;
}
@@ -238,6 +270,16 @@ class BahmcloudStorePanel extends HTMLElement {
button:active{ transform: translateY(0px); box-shadow:none; }
button:disabled{ opacity: 0.55; cursor: not-allowed; }
.statusline{
margin-left: 10px;
font-size: 12px;
color: var(--secondary-text-color);
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card{
border:1px solid var(--divider-color);
background:var(--card-background-color);
@@ -363,6 +405,7 @@ class BahmcloudStorePanel extends HTMLElement {
</div>
<div class="right">
<button id="refreshBtn" class="primary">Refresh</button>
<div class="statusline" id="statusLine"></div>
</div>
</div>
@@ -380,7 +423,8 @@ class BahmcloudStorePanel extends HTMLElement {
<div id="fabs"></div>
`;
root.getElementById("refreshBtn").addEventListener("click", () => this._load());
// IMPORTANT: Refresh must trigger backend refresh, not only GET /api/bcs
root.getElementById("refreshBtn").addEventListener("click", () => this._refreshAll());
root.getElementById("menuBtn").addEventListener("click", () => this._toggleMenu());
root.getElementById("backBtn").addEventListener("click", () => this._goBack());
@@ -447,6 +491,15 @@ class BahmcloudStorePanel extends HTMLElement {
const err = root.getElementById("error");
const subtitle = root.getElementById("subtitle");
const fabs = root.getElementById("fabs");
const statusLine = root.getElementById("statusLine");
const refreshBtn = root.getElementById("refreshBtn");
if (statusLine) statusLine.textContent = this._status || "";
if (refreshBtn) {
refreshBtn.disabled = !!this._refreshing;
refreshBtn.textContent = this._refreshing ? "Refreshing…" : "Refresh";
}
const v = this._safeText(this._data?.version);
subtitle.textContent = v ? `BCS ${v}` : "BCS — loading…";
@@ -497,6 +550,7 @@ class BahmcloudStorePanel extends HTMLElement {
this._restoreFocusState(focusState);
}
// ---------- everything below is unchanged from your current file ----------
_renderStore() {
const repos = Array.isArray(this._data.repos) ? this._data.repos : [];
const categories = this._computeCategories(repos);
@@ -758,7 +812,6 @@ class BahmcloudStorePanel extends HTMLElement {
if (!mount) return;
if (this._readmeText) {
// Client renderer may be unavailable; prefer server-provided HTML
if (this._readmeHtml) {
mount.innerHTML = this._readmeHtml;
this._postprocessRenderedMarkdown(mount);
@@ -920,7 +973,7 @@ class BahmcloudStorePanel extends HTMLElement {
const t = typeof v;
if (t === "string") return v;
if (t === "number" || t === "boolean") return String(v);
return ""; // objects/arrays/functions => empty (prevents [object Object])
return "";
}
_safeLower(v) {

View File

@@ -16,14 +16,12 @@ _LOGGER = logging.getLogger(__name__)
def _render_markdown_server_side(md: str) -> str | None:
"""Render Markdown -> sanitized HTML (server-side)."""
text = (md or "").strip()
if not text:
return None
html: str | None = None
# 1) python-markdown
try:
import markdown as mdlib # type: ignore
@@ -39,7 +37,6 @@ def _render_markdown_server_side(md: str) -> str | None:
if not html:
return None
# 2) Sanitize via bleach
try:
import bleach # type: ignore
@@ -124,16 +121,6 @@ def _maybe_decode_base64(content: str, encoding: Any) -> str | None:
def _extract_text_recursive(obj: Any, depth: int = 0) -> str | None:
"""
Robust extraction for README markdown.
Handles:
- str / bytes
- dict with:
- {content: "...", encoding: "base64"} (possibly nested)
- {readme: "..."} etc.
- list of dicts (pick first matching)
"""
if obj is None:
return None
@@ -150,21 +137,16 @@ def _extract_text_recursive(obj: Any, depth: int = 0) -> str | None:
return None
if isinstance(obj, dict):
# 1) If it looks like "file content"
content = obj.get("content")
encoding = obj.get("encoding")
# Base64 decode if possible
decoded = _maybe_decode_base64(content, encoding)
if decoded:
return decoded
# content may already be plain text
if isinstance(content, str) and (not isinstance(encoding, str) or not encoding.strip()):
# Heuristic: treat as markdown if it has typical markdown chars, otherwise still return
return content
# 2) direct text keys (readme/markdown/text/body/data)
for k in _TEXT_KEYS:
v = obj.get(k)
if isinstance(v, str):
@@ -175,7 +157,6 @@ def _extract_text_recursive(obj: Any, depth: int = 0) -> str | None:
except Exception:
pass
# 3) Sometimes nested under "file" / "result" / "payload" etc.
for v in obj.values():
out = _extract_text_recursive(v, depth + 1)
if out:
@@ -198,7 +179,7 @@ class StaticAssetsView(HomeAssistantView):
name = "api:bahmcloud_store_static"
requires_auth = False
async def get(self, request: web.Request, path: str) -> web.Response:
async def get(self, request: web.Request, path: str) -> web.StreamResponse:
base = Path(__file__).resolve().parent / "panel"
base_resolved = base.resolve()
@@ -218,24 +199,7 @@ class StaticAssetsView(HomeAssistantView):
_LOGGER.error("BCS static asset not found: %s", target)
return web.Response(status=404)
content_type = "text/plain"
charset = None
if target.suffix == ".js":
content_type = "application/javascript"
charset = "utf-8"
elif target.suffix == ".html":
content_type = "text/html"
charset = "utf-8"
elif target.suffix == ".css":
content_type = "text/css"
charset = "utf-8"
elif target.suffix == ".svg":
content_type = "image/svg+xml"
elif target.suffix == ".png":
content_type = "image/png"
resp = web.Response(body=target.read_bytes(), content_type=content_type, charset=charset)
resp = web.FileResponse(path=target)
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
resp.headers["Pragma"] = "no-cache"
return resp
@@ -247,7 +211,7 @@ class BCSApiView(HomeAssistantView):
requires_auth = True
def __init__(self, core: Any) -> None:
self.core = core
self.core: BCSCore = core
async def get(self, request: web.Request) -> web.Response:
return web.json_response(
@@ -255,7 +219,21 @@ class BCSApiView(HomeAssistantView):
)
async def post(self, request: web.Request) -> web.Response:
data = await request.json()
action = request.query.get("action")
if action == "refresh":
_LOGGER.info("BCS manual refresh triggered via API")
try:
await self.core.full_refresh(source="manual")
return web.json_response({"ok": True})
except Exception as e:
_LOGGER.error("BCS manual refresh failed: %s", e)
return web.json_response({"ok": False, "message": "Refresh failed"}, status=500)
try:
data = await request.json()
except Exception:
data = {}
op = data.get("op")
if op == "add_custom_repo":
@@ -276,7 +254,7 @@ class BCSCustomRepoView(HomeAssistantView):
requires_auth = True
def __init__(self, core: Any) -> None:
self.core = core
self.core: BCSCore = core
async def delete(self, request: web.Request) -> web.Response:
repo_id = request.query.get("id")
@@ -292,7 +270,7 @@ class BCSReadmeView(HomeAssistantView):
requires_auth = True
def __init__(self, core: Any) -> None:
self.core = core
self.core: BCSCore = core
async def get(self, request: web.Request) -> web.Response:
repo_id = request.query.get("repo_id")
@@ -309,8 +287,6 @@ class BCSReadmeView(HomeAssistantView):
status=404,
)
# Ensure strict JSON string output (avoid accidental objects)
md_str = str(md)
html = _render_markdown_server_side(md_str)
return web.json_response({"ok": True, "readme": md_str, "html": html})