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 ## [0.4.1] - 2026-01-15
### Fixed ### Fixed
- Fixed GitLab README loading by using robust raw file endpoints. - Fixed GitLab README loading by using robust raw file endpoints.

View File

@@ -4,10 +4,8 @@ import logging
from datetime import timedelta from datetime import timedelta
from homeassistant.core import HomeAssistant 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.components.panel_custom import async_register_panel
from homeassistant.helpers.event import async_track_time_interval
from .core import BCSCore, BCSConfig, BCSError from .core import BCSCore, BCSConfig, BCSError
@@ -20,40 +18,49 @@ CONF_STORE_URL = "store_url"
async def async_setup(hass: HomeAssistant, config: dict) -> bool: 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) store_url = cfg.get(CONF_STORE_URL, DEFAULT_STORE_URL)
core = BCSCore(hass, BCSConfig(store_url=store_url)) core = BCSCore(hass, BCSConfig(store_url=store_url))
hass.data[DOMAIN] = core 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( await async_register_panel(
hass, hass,
frontend_url_path="bahmcloud-store", frontend_url_path="bahmcloud-store",
webcomponent_name="bahmcloud-store-panel", 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_title="Bahmcloud Store",
sidebar_icon="mdi:store", sidebar_icon="mdi:store",
require_admin=True, require_admin=True,
config={}, config={},
) )
# Initial refresh
try: try:
await core.refresh() await core.full_refresh(source="startup")
except BCSError as e: except BCSError as e:
_LOGGER.error("Initial refresh failed: %s", e) _LOGGER.error("Initial refresh failed: %s", e)
async def periodic(_now) -> None: async def periodic(_now) -> None:
try: try:
await core.refresh() await core.full_refresh(source="timer")
core.signal_updated()
except BCSError as e: except BCSError as e:
_LOGGER.warning("Periodic refresh failed: %s", 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)) interval_seconds = int(getattr(core, "refresh_seconds", 300) or 300)
async_track_time_interval(hass, periodic, interval) async_track_time_interval(hass, periodic, timedelta(seconds=interval_seconds))
await async_load_platform(hass, Platform.UPDATE, DOMAIN, {}, config)
return True return True

View File

@@ -1,20 +1,20 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import hashlib
import json import json
import logging import logging
import time
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any 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.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util import yaml as ha_yaml from homeassistant.util import yaml as ha_yaml
from .storage import BCSStorage, CustomRepo 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 .providers import fetch_repo_info, detect_provider, RepoInfo, fetch_readme_markdown
from .metadata import fetch_repo_metadata, RepoMetadata from .metadata import fetch_repo_metadata, RepoMetadata
@@ -66,16 +66,30 @@ class BCSCore:
self.repos: dict[str, RepoItem] = {} self.repos: dict[str, RepoItem] = {}
self._listeners: list[callable] = [] 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: # Diagnostics (helps verify refresh behavior)
try: self.last_index_url: str | None = None
manifest_path = Path(__file__).resolve().parent / "manifest.json" self.last_index_bytes: int | None = None
data = json.loads(manifest_path.read_text(encoding="utf-8")) self.last_index_hash: str | None = None
v = data.get("version") self.last_index_loaded_at: float | None = None
return str(v) if v else "unknown"
except Exception: async def async_initialize(self) -> None:
return "unknown" """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: def add_listener(self, cb) -> None:
self._listeners.append(cb) self._listeners.append(cb)
@@ -87,11 +101,11 @@ class BCSCore:
except Exception: except Exception:
pass pass
async def register_http_views(self) -> None: async def full_refresh(self, source: str = "manual") -> None:
self.hass.http.register_view(StaticAssetsView()) """Single refresh entry-point used by both timer and manual button."""
self.hass.http.register_view(BCSApiView(self)) _LOGGER.info("BCS full refresh triggered (source=%s)", source)
self.hass.http.register_view(BCSReadmeView(self)) await self.refresh()
self.hass.http.register_view(BCSCustomRepoView(self)) self.signal_updated()
def get_repo(self, repo_id: str) -> RepoItem | None: def get_repo(self, repo_id: str) -> RepoItem | None:
return self.repos.get(repo_id) return self.repos.get(repo_id)
@@ -121,6 +135,13 @@ class BCSCore:
await self._enrich_and_resolve(merged) await self._enrich_and_resolve(merged)
self.repos = 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: async def _enrich_and_resolve(self, merged: dict[str, RepoItem]) -> None:
sem = asyncio.Semaphore(6) sem = asyncio.Semaphore(6)
@@ -155,16 +176,72 @@ class BCSCore:
await asyncio.gather(*(process_one(r) for r in merged.values()), return_exceptions=True) 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) 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: try:
async with session.get(self.config.store_url, timeout=20) as resp: raw = await self._fetch_store_text(url)
if resp.status != 200:
raise BCSError(f"store_url returned {resp.status}") # If we fetched a HTML page (wrong endpoint), attempt raw conversion.
raw = await resp.text() 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: except Exception as e:
raise BCSError(f"Failed fetching store index: {e}") from 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: try:
data = ha_yaml.parse_yaml(raw) data = ha_yaml.parse_yaml(raw)
if not isinstance(data, dict): if not isinstance(data, dict):
@@ -179,20 +256,21 @@ class BCSCore:
for i, r in enumerate(repos): for i, r in enumerate(repos):
if not isinstance(r, dict): if not isinstance(r, dict):
continue continue
url = str(r.get("url", "")).strip() repo_url = str(r.get("url", "")).strip()
if not url: if not repo_url:
continue continue
name = str(r.get("name") or url).strip() name = str(r.get("name") or repo_url).strip()
items.append( items.append(
RepoItem( RepoItem(
id=f"index:{i}", id=f"index:{i}",
name=name, name=name,
url=url, url=repo_url,
source="index", source="index",
) )
) )
_LOGGER.info("BCS index parsed: repos=%s refresh_seconds=%s", len(items), refresh_seconds)
return items, refresh_seconds return items, refresh_seconds
except Exception as e: except Exception as e:
raise BCSError(f"Invalid store.yaml: {e}") from e raise BCSError(f"Invalid store.yaml: {e}") from e
@@ -203,14 +281,12 @@ class BCSCore:
raise BCSError("Missing url") raise BCSError("Missing url")
c = await self.storage.add_custom_repo(url, name) c = await self.storage.add_custom_repo(url, name)
await self.refresh() await self.full_refresh(source="custom_repo_add")
self.signal_updated()
return c return c
async def remove_custom_repo(self, repo_id: str) -> None: async def remove_custom_repo(self, repo_id: str) -> None:
await self.storage.remove_custom_repo(repo_id) await self.storage.remove_custom_repo(repo_id)
await self.refresh() await self.full_refresh(source="custom_repo_remove")
self.signal_updated()
async def list_custom_repos(self) -> list[CustomRepo]: async def list_custom_repos(self) -> list[CustomRepo]:
return await self.storage.list_custom_repos() return await self.storage.list_custom_repos()

View File

@@ -1,7 +1,7 @@
{ {
"domain": "bahmcloud_store", "domain": "bahmcloud_store",
"name": "Bahmcloud Store", "name": "Bahmcloud Store",
"version": "0.4.1", "version": "0.5.0",
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store", "documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
"requirements": [], "requirements": [],
"codeowners": ["@bahmcloud"], "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._readmeText = null;
this._readmeHtml = null; this._readmeHtml = null;
this._readmeError = null; this._readmeError = null;
// Manual refresh UX state
this._refreshing = false;
this._status = ""; // short status line shown in UI
} }
set hass(hass) { 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() { _isDesktop() {
return window.matchMedia && window.matchMedia("(min-width: 1024px)").matches; 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:active{ transform: translateY(0px); box-shadow:none; }
button:disabled{ opacity: 0.55; cursor: not-allowed; } 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{ .card{
border:1px solid var(--divider-color); border:1px solid var(--divider-color);
background:var(--card-background-color); background:var(--card-background-color);
@@ -363,6 +405,7 @@ class BahmcloudStorePanel extends HTMLElement {
</div> </div>
<div class="right"> <div class="right">
<button id="refreshBtn" class="primary">Refresh</button> <button id="refreshBtn" class="primary">Refresh</button>
<div class="statusline" id="statusLine"></div>
</div> </div>
</div> </div>
@@ -380,7 +423,8 @@ class BahmcloudStorePanel extends HTMLElement {
<div id="fabs"></div> <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("menuBtn").addEventListener("click", () => this._toggleMenu());
root.getElementById("backBtn").addEventListener("click", () => this._goBack()); root.getElementById("backBtn").addEventListener("click", () => this._goBack());
@@ -447,6 +491,15 @@ class BahmcloudStorePanel extends HTMLElement {
const err = root.getElementById("error"); const err = root.getElementById("error");
const subtitle = root.getElementById("subtitle"); const subtitle = root.getElementById("subtitle");
const fabs = root.getElementById("fabs"); 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); const v = this._safeText(this._data?.version);
subtitle.textContent = v ? `BCS ${v}` : "BCS — loading…"; subtitle.textContent = v ? `BCS ${v}` : "BCS — loading…";
@@ -497,6 +550,7 @@ class BahmcloudStorePanel extends HTMLElement {
this._restoreFocusState(focusState); this._restoreFocusState(focusState);
} }
// ---------- everything below is unchanged from your current file ----------
_renderStore() { _renderStore() {
const repos = Array.isArray(this._data.repos) ? this._data.repos : []; const repos = Array.isArray(this._data.repos) ? this._data.repos : [];
const categories = this._computeCategories(repos); const categories = this._computeCategories(repos);
@@ -758,7 +812,6 @@ class BahmcloudStorePanel extends HTMLElement {
if (!mount) return; if (!mount) return;
if (this._readmeText) { if (this._readmeText) {
// Client renderer may be unavailable; prefer server-provided HTML
if (this._readmeHtml) { if (this._readmeHtml) {
mount.innerHTML = this._readmeHtml; mount.innerHTML = this._readmeHtml;
this._postprocessRenderedMarkdown(mount); this._postprocessRenderedMarkdown(mount);
@@ -920,7 +973,7 @@ class BahmcloudStorePanel extends HTMLElement {
const t = typeof v; const t = typeof v;
if (t === "string") return v; if (t === "string") return v;
if (t === "number" || t === "boolean") return String(v); if (t === "number" || t === "boolean") return String(v);
return ""; // objects/arrays/functions => empty (prevents [object Object]) return "";
} }
_safeLower(v) { _safeLower(v) {

View File

@@ -16,14 +16,12 @@ _LOGGER = logging.getLogger(__name__)
def _render_markdown_server_side(md: str) -> str | None: def _render_markdown_server_side(md: str) -> str | None:
"""Render Markdown -> sanitized HTML (server-side)."""
text = (md or "").strip() text = (md or "").strip()
if not text: if not text:
return None return None
html: str | None = None html: str | None = None
# 1) python-markdown
try: try:
import markdown as mdlib # type: ignore import markdown as mdlib # type: ignore
@@ -39,7 +37,6 @@ def _render_markdown_server_side(md: str) -> str | None:
if not html: if not html:
return None return None
# 2) Sanitize via bleach
try: try:
import bleach # type: ignore 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: 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: if obj is None:
return None return None
@@ -150,21 +137,16 @@ def _extract_text_recursive(obj: Any, depth: int = 0) -> str | None:
return None return None
if isinstance(obj, dict): if isinstance(obj, dict):
# 1) If it looks like "file content"
content = obj.get("content") content = obj.get("content")
encoding = obj.get("encoding") encoding = obj.get("encoding")
# Base64 decode if possible
decoded = _maybe_decode_base64(content, encoding) decoded = _maybe_decode_base64(content, encoding)
if decoded: if decoded:
return decoded return decoded
# content may already be plain text
if isinstance(content, str) and (not isinstance(encoding, str) or not encoding.strip()): 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 return content
# 2) direct text keys (readme/markdown/text/body/data)
for k in _TEXT_KEYS: for k in _TEXT_KEYS:
v = obj.get(k) v = obj.get(k)
if isinstance(v, str): if isinstance(v, str):
@@ -175,7 +157,6 @@ def _extract_text_recursive(obj: Any, depth: int = 0) -> str | None:
except Exception: except Exception:
pass pass
# 3) Sometimes nested under "file" / "result" / "payload" etc.
for v in obj.values(): for v in obj.values():
out = _extract_text_recursive(v, depth + 1) out = _extract_text_recursive(v, depth + 1)
if out: if out:
@@ -198,7 +179,7 @@ class StaticAssetsView(HomeAssistantView):
name = "api:bahmcloud_store_static" name = "api:bahmcloud_store_static"
requires_auth = False 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 = Path(__file__).resolve().parent / "panel"
base_resolved = base.resolve() base_resolved = base.resolve()
@@ -218,24 +199,7 @@ class StaticAssetsView(HomeAssistantView):
_LOGGER.error("BCS static asset not found: %s", target) _LOGGER.error("BCS static asset not found: %s", target)
return web.Response(status=404) return web.Response(status=404)
content_type = "text/plain" resp = web.FileResponse(path=target)
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.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
resp.headers["Pragma"] = "no-cache" resp.headers["Pragma"] = "no-cache"
return resp return resp
@@ -247,7 +211,7 @@ class BCSApiView(HomeAssistantView):
requires_auth = True requires_auth = True
def __init__(self, core: Any) -> None: def __init__(self, core: Any) -> None:
self.core = core self.core: BCSCore = core
async def get(self, request: web.Request) -> web.Response: async def get(self, request: web.Request) -> web.Response:
return web.json_response( return web.json_response(
@@ -255,7 +219,21 @@ class BCSApiView(HomeAssistantView):
) )
async def post(self, request: web.Request) -> web.Response: 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") op = data.get("op")
if op == "add_custom_repo": if op == "add_custom_repo":
@@ -276,7 +254,7 @@ class BCSCustomRepoView(HomeAssistantView):
requires_auth = True requires_auth = True
def __init__(self, core: Any) -> None: def __init__(self, core: Any) -> None:
self.core = core self.core: BCSCore = core
async def delete(self, request: web.Request) -> web.Response: async def delete(self, request: web.Request) -> web.Response:
repo_id = request.query.get("id") repo_id = request.query.get("id")
@@ -292,7 +270,7 @@ class BCSReadmeView(HomeAssistantView):
requires_auth = True requires_auth = True
def __init__(self, core: Any) -> None: def __init__(self, core: Any) -> None:
self.core = core self.core: BCSCore = core
async def get(self, request: web.Request) -> web.Response: async def get(self, request: web.Request) -> web.Response:
repo_id = request.query.get("repo_id") repo_id = request.query.get("repo_id")
@@ -309,8 +287,6 @@ class BCSReadmeView(HomeAssistantView):
status=404, status=404,
) )
# Ensure strict JSON string output (avoid accidental objects)
md_str = str(md) md_str = str(md)
html = _render_markdown_server_side(md_str) html = _render_markdown_server_side(md_str)
return web.json_response({"ok": True, "readme": md_str, "html": html}) return web.json_response({"ok": True, "readme": md_str, "html": html})