41 Commits

Author SHA1 Message Date
518ac1d59d add 0.5.11 2026-01-18 07:47:24 +00:00
ad699dc69a 0.5.11 2026-01-18 07:45:56 +00:00
a8e247d288 Add backup 2026-01-18 07:45:34 +00:00
318d517575 0.5.10 2026-01-17 20:49:10 +00:00
db137be5b1 0.5.1p 2026-01-17 20:48:07 +00:00
83cec0f75a 0.5.10 2026-01-17 20:47:39 +00:00
cda9914d50 add 0.5.9 2026-01-17 19:47:29 +00:00
3acefbfbe8 0.5.9 2026-01-17 19:46:44 +00:00
4d10c5c91e Readme show more 2026-01-17 19:45:59 +00:00
810ff6fe85 Readme Show more. 2026-01-17 19:45:26 +00:00
b2d3d940f2 add 0.5.8 2026-01-17 19:05:55 +00:00
8b1d828c59 add 0.5.8 2026-01-17 19:04:45 +00:00
824a9e5cad 0.5.8 2026-01-17 19:03:51 +00:00
1cbc204e88 Fix readme 2026-01-17 19:02:16 +00:00
561c323e67 Fix readme 2026-01-17 19:01:43 +00:00
5c604b40c6 bcs.yaml aktualisiert 2026-01-17 12:00:05 +00:00
cc8db6a034 Add 0.5.7 2026-01-17 11:01:14 +00:00
e0ad133221 0.5.7 2026-01-17 11:00:36 +00:00
0e27a03aaf Fix laxout mobile 2026-01-17 10:59:48 +00:00
e2dfa20789 0.5.6 2026-01-17 08:39:24 +00:00
8e8b58d2d2 0.5.6 2026-01-17 08:37:09 +00:00
76ecaabd98 0.5.6 2026-01-17 08:35:21 +00:00
3f14dc3bd9 Buttons add 8n panel 2026-01-17 08:16:54 +00:00
50a78714cc Button gix 2026-01-17 08:16:10 +00:00
3bf01c91f1 Button fix 2026-01-17 08:15:32 +00:00
7aa14284dd Button fix 2026-01-17 08:14:59 +00:00
24933e980d Button 2026-01-17 08:13:22 +00:00
e10624df6b Button 1 2026-01-17 08:04:02 +00:00
1a1ebd3821 Button 2026-01-17 08:01:43 +00:00
d3d61067db Button 2026-01-17 08:01:23 +00:00
23b605becf Button delete 2026-01-17 07:59:20 +00:00
c07f8615e4 Add 0.5.5 2026-01-16 20:18:34 +00:00
9b209a15bf 0.5.5 2026-01-16 20:17:28 +00:00
30258bd2c0 Fix 0.5.4 to. 5 2026-01-16 20:15:11 +00:00
2c8ca490ea Add 0.5.4 2026-01-16 20:06:08 +00:00
9e8a8e81b9 0.5.4 fix 2026-01-16 20:05:20 +00:00
f5b2534fdb 0.5.4 2026-01-16 20:02:24 +00:00
8b3916c3fa 0.5.4 2026-01-16 19:59:33 +00:00
13e71046f8 Add on 0.5.4 2026-01-16 19:58:58 +00:00
58e3674325 0.5.4 2026-01-16 19:57:49 +00:00
828d84caa3 0.5.3 2026-01-16 19:55:20 +00:00
12 changed files with 1385 additions and 482 deletions

View File

@@ -11,6 +11,81 @@ Sections:
--- ---
## [0.5.11] - 2026-01-18
### Added
- Automatic backup of existing custom components before install or update.
- Backup retention with a configurable limit per domain.
### Safety
- Automatic rollback is triggered if an install or update fails after a backup was created.
## [0.5.10] - 2026-01-17
### Added
- Store view controls: Filter and Sort dropdowns alongside the existing Category selector.
### Fixed
- Search input focus is preserved while typing (cursor no longer jumps out after re-render).
## [0.5.9] - 2026-01-17
### Changed
- README is now collapsible with a preview by default (Show more / Show less).
- Improved mobile readability by keeping long README content contained without affecting the page layout.
## [0.5.8] - 2026-01-17
### Changed
- Mobile UI layout stabilized to prevent horizontal shifting.
- README rendering no longer expands the page width on mobile devices.
- Tables and code blocks inside README now scroll within their container.
- Floating action buttons removed to avoid UI overlap on small screens.
- Header icon buttons improved for better visibility in light and dark mode.
## [0.5.7] - 2026-01-17
### Changed
- Mobile UI improvements: removed floating action buttons to prevent overlay issues.
- Improved responsive layout to avoid horizontal overflow (badges, URLs, descriptions).
- README rendering on mobile is more stable (better wrapping and image scaling).
- Header icon buttons are more readable in both light and dark mode.
## [0.5.6] - 2026-01-17
### Added
- Repository uninstall support directly from the Store UI.
- New backend API endpoint: `POST /api/bcs/uninstall`.
- Automatic **reconcile**: repositories are marked as not installed when their `custom_components` directories are removed manually.
### Changed
- Installation & Updates section extended with an Uninstall button.
- Store state now remains consistent even after manual file system changes.
### Fixed
- Repositories remained marked as installed after manual deletion of their domains.
- UI cache issues caused by outdated static assets.
## [0.5.5] - 2026-01-16
### Fixed
- Update entities now refresh their displayed name after store refreshes, so repository names replace fallback IDs (e.g. `index:1`) reliably.
## [0.5.4] - 2026-01-16
### Added
- Native **Repair fix flow** for restart-required situations.
- “Restart required” issues are now **fixable** and provide a confirmation dialog with a real restart action.
### Changed
- Restart-required issues are automatically cleared after Home Assistant restarts.
- Update entities now fully align with official Home Assistant behavior (Updates screen + Repairs integration).
### Fixed
- Fixed integration startup issues caused by incorrect file placement.
- Resolved circular import and missing setup errors during Home Assistant startup.
- Ensured YAML-based setup remains fully supported.
## [0.5.3] - 2026-01-16 ## [0.5.3] - 2026-01-16
### Added ### Added

View File

@@ -4,7 +4,7 @@ description: >
Supports GitHub, GitLab, Gitea and Bahmcloud repositories with Supports GitHub, GitLab, Gitea and Bahmcloud repositories with
a central index, UI panel and API, similar to HACS but independent. a central index, UI panel and API, similar to HACS but independent.
category: Store category: integration
author: Bahmcloud author: Bahmcloud
maintainer: Bahmcloud maintainer: Bahmcloud

View File

@@ -1,166 +1,82 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from dataclasses import dataclass from datetime import timedelta
from typing import Any
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback from homeassistant.components.panel_custom import async_register_panel
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.entity import EntityCategory
from .core import DOMAIN, SIGNAL_UPDATED, BCSCore from .core import BCSCore, BCSConfig, BCSError
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DOMAIN = "bahmcloud_store"
DEFAULT_STORE_URL = "https://git.bahmcloud.de/bahmcloud/ha_store/raw/branch/main/store.yaml"
CONF_STORE_URL = "store_url"
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
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.async_initialize()
# Provide native Update entities in Settings -> System -> Updates.
# This integration is YAML-based (async_setup), therefore we load the platform manually.
await async_load_platform(hass, "update", DOMAIN, {}, config)
from .views import (
StaticAssetsView,
BCSApiView,
BCSReadmeView,
BCSCustomRepoView,
BCSInstallView,
BCSUpdateView,
BCSUninstallView,
BCSRestartView,
)
hass.http.register_view(StaticAssetsView())
hass.http.register_view(BCSApiView(core))
hass.http.register_view(BCSReadmeView(core))
hass.http.register_view(BCSCustomRepoView(core))
hass.http.register_view(BCSInstallView(core))
hass.http.register_view(BCSUpdateView(core))
hass.http.register_view(BCSUninstallView(core))
hass.http.register_view(BCSRestartView(core))
await async_register_panel(
hass,
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=102",
sidebar_title="Bahmcloud Store",
sidebar_icon="mdi:store",
require_admin=True,
config={},
)
def _pretty_repo_name(core: BCSCore, repo_id: str) -> str:
"""Return a human-friendly name for a repo update entity."""
try: try:
repo = core.get_repo(repo_id) await core.full_refresh(source="startup")
if repo and getattr(repo, "name", None): except BCSError as e:
name = str(repo.name).strip() _LOGGER.error("Initial refresh failed: %s", e)
if name:
return name
except Exception:
pass
# Fallbacks
if repo_id.startswith("index:"):
return f"BCS Index {repo_id.split(':', 1)[1]}"
if repo_id.startswith("custom:"):
return f"BCS Custom {repo_id.split(':', 1)[1]}"
return f"BCS {repo_id}"
@dataclass(frozen=True)
class _RepoKey:
repo_id: str
class BCSRepoUpdateEntity(UpdateEntity):
"""Update entity representing a BCS-managed repository."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_supported_features = UpdateEntityFeature.INSTALL
def __init__(self, core: BCSCore, repo_id: str) -> None:
self._core = core
self._repo_id = repo_id
self._in_progress = False
# Stable unique id (do NOT change)
self._attr_unique_id = f"{DOMAIN}:{repo_id}"
# Human-friendly name in UI
pretty = _pretty_repo_name(core, repo_id)
self._attr_name = pretty
# Title shown in the entity dialog
self._attr_title = pretty
@property
def available(self) -> bool:
repo = self._core.get_repo(self._repo_id)
installed = self._core.get_installed(self._repo_id)
return repo is not None and installed is not None
@property
def in_progress(self) -> bool | None:
return self._in_progress
@property
def installed_version(self) -> str | None:
installed = self._core.get_installed(self._repo_id) or {}
v = installed.get("installed_version") or installed.get("ref")
return str(v) if v else None
@property
def latest_version(self) -> str | None:
repo = self._core.get_repo(self._repo_id)
if not repo:
return None
v = getattr(repo, "latest_version", None)
return str(v) if v else None
@property
def update_available(self) -> bool:
latest = self.latest_version
installed = self.installed_version
if not latest or not installed:
return False
return latest != installed
def version_is_newer(self, latest_version: str, installed_version: str) -> bool:
return latest_version != installed_version
@property
def release_url(self) -> str | None:
repo = self._core.get_repo(self._repo_id)
return getattr(repo, "url", None) if repo else None
async def async_install(self, version: str | None, backup: bool, **kwargs: Any) -> None:
if version is not None:
_LOGGER.debug(
"BCS update entity requested specific version=%s (ignored)", version
)
self._in_progress = True
self.async_write_ha_state()
async def periodic(_now) -> None:
try: try:
await self._core.update_repo(self._repo_id) await core.full_refresh(source="timer")
finally: except BCSError as e:
self._in_progress = False _LOGGER.warning("Periodic refresh failed: %s", e)
self.async_write_ha_state() except Exception as e: # pylint: disable=broad-exception-caught
_LOGGER.exception("Unexpected error during periodic refresh: %s", e)
interval_seconds = int(getattr(core, "refresh_seconds", 300) or 300)
async_track_time_interval(hass, periodic, timedelta(seconds=interval_seconds))
@callback return True
def _sync_entities(
core: BCSCore,
existing: dict[str, BCSRepoUpdateEntity],
async_add_entities: AddEntitiesCallback,
) -> None:
"""Ensure there is one update entity per installed repo."""
installed_map = getattr(core, "_installed_cache", {}) or {}
new_entities: list[BCSRepoUpdateEntity] = []
for repo_id, data in installed_map.items():
if not isinstance(data, dict):
continue
if repo_id in existing:
continue
ent = BCSRepoUpdateEntity(core, repo_id)
existing[repo_id] = ent
new_entities.append(ent)
if new_entities:
async_add_entities(new_entities)
for ent in existing.values():
ent.async_write_ha_state()
async def async_setup_platform(
hass: HomeAssistant,
config,
async_add_entities: AddEntitiesCallback,
discovery_info=None,
):
"""Set up BCS update entities."""
core: BCSCore | None = hass.data.get(DOMAIN)
if not core:
_LOGGER.debug("BCS core not available, skipping update platform setup")
return
entities: dict[str, BCSRepoUpdateEntity] = {}
_sync_entities(core, entities, async_add_entities)
@callback
def _handle_update() -> None:
_sync_entities(core, entities, async_add_entities)
async_dispatcher_connect(hass, SIGNAL_UPDATED, _handle_update)

View File

@@ -1,166 +1,789 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import hashlib
import json
import logging import logging
import time
import shutil
import tempfile
import zipfile
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit, urlparse
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.entity import EntityCategory from homeassistant.util import yaml as ha_yaml
from .core import DOMAIN, SIGNAL_UPDATED, BCSCore from .storage import BCSStorage, CustomRepo
from .providers import fetch_repo_info, detect_provider, RepoInfo, fetch_readme_markdown
from .metadata import fetch_repo_metadata, RepoMetadata
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DOMAIN = "bahmcloud_store"
def _pretty_repo_name(core: BCSCore, repo_id: str) -> str: SIGNAL_UPDATED = f"{DOMAIN}_updated"
"""Return a human-friendly name for a repo update entity.""" RESTART_REQUIRED_ISSUE_ID = "restart_required"
try:
repo = core.get_repo(repo_id)
if repo and getattr(repo, "name", None):
name = str(repo.name).strip()
if name:
return name
except Exception:
pass
# Fallbacks
if repo_id.startswith("index:"):
return f"BCS Index {repo_id.split(':', 1)[1]}"
if repo_id.startswith("custom:"):
return f"BCS Custom {repo_id.split(':', 1)[1]}"
return f"BCS {repo_id}"
@dataclass(frozen=True) class BCSError(Exception):
class _RepoKey: """BCS core error."""
repo_id: str
class BCSRepoUpdateEntity(UpdateEntity): class BCSInstallError(BCSError):
"""Update entity representing a BCS-managed repository.""" """BCS installation/update error."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_supported_features = UpdateEntityFeature.INSTALL
def __init__(self, core: BCSCore, repo_id: str) -> None: @dataclass
self._core = core class BCSConfig:
self._repo_id = repo_id store_url: str
self._in_progress = False
# Stable unique id (do NOT change)
self._attr_unique_id = f"{DOMAIN}:{repo_id}"
# Human-friendly name in UI @dataclass
pretty = _pretty_repo_name(core, repo_id) class RepoItem:
self._attr_name = pretty id: str
name: str
url: str
source: str # "index" | "custom"
# Title shown in the entity dialog owner: str | None = None
self._attr_title = pretty provider: str | None = None
provider_repo_name: str | None = None
provider_description: str | None = None
default_branch: str | None = None
@property latest_version: str | None = None
def available(self) -> bool: latest_version_source: str | None = None # "release" | "tag" | "atom" | None
repo = self._core.get_repo(self._repo_id)
installed = self._core.get_installed(self._repo_id)
return repo is not None and installed is not None
@property meta_source: str | None = None
def in_progress(self) -> bool | None: meta_name: str | None = None
return self._in_progress meta_description: str | None = None
meta_category: str | None = None
meta_author: str | None = None
meta_maintainer: str | None = None
@property
def installed_version(self) -> str | None:
installed = self._core.get_installed(self._repo_id) or {}
v = installed.get("installed_version") or installed.get("ref")
return str(v) if v else None
@property class BCSCore:
def latest_version(self) -> str | None: def __init__(self, hass: HomeAssistant, config: BCSConfig) -> None:
repo = self._core.get_repo(self._repo_id) self.hass = hass
if not repo: self.config = config
return None self.storage = BCSStorage(hass)
v = getattr(repo, "latest_version", None)
return str(v) if v else None
@property self.refresh_seconds: int = 300
def update_available(self) -> bool: self.repos: dict[str, RepoItem] = {}
latest = self.latest_version self._listeners: list[callable] = []
installed = self.installed_version
if not latest or not installed:
return False
return latest != installed
def version_is_newer(self, latest_version: str, installed_version: str) -> bool: # Will be loaded asynchronously (no blocking IO in event loop)
return latest_version != installed_version self.version: str = "unknown"
@property # Diagnostics (helps verify refresh behavior)
def release_url(self) -> str | None: self.last_index_url: str | None = None
repo = self._core.get_repo(self._repo_id) self.last_index_bytes: int | None = None
return getattr(repo, "url", None) if repo else None self.last_index_hash: str | None = None
self.last_index_loaded_at: float | None = None
async def async_install(self, version: str | None, backup: bool, **kwargs: Any) -> None: self._install_lock = asyncio.Lock()
if version is not None: self._installed_cache: dict[str, Any] = {}
_LOGGER.debug(
"BCS update entity requested specific version=%s (ignored)", version # Phase F2: backups before install/update
self._backup_root = Path(self.hass.config.path(".bcs_backups"))
self._backup_keep_per_domain: int = 5
async def async_initialize(self) -> None:
"""Async initialization that avoids blocking file IO."""
self.version = await self._read_manifest_version_async()
await self._refresh_installed_cache()
# After a successful HA restart, restart-required is no longer relevant.
self._clear_restart_required_issue()
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)
def signal_updated(self) -> None:
# Notify entities/platforms (e.g. update entities) that BCS data changed.
async_dispatcher_send(self.hass, SIGNAL_UPDATED)
for cb in list(self._listeners):
try:
cb()
except Exception:
pass
def _mark_restart_required(self) -> None:
"""Show a 'restart required' issue in Home Assistant Settings.
IMPORTANT:
- is_fixable=True enables the "Fix/OK" button
- the real action is implemented in repairs.py (fix flow)
"""
try:
ir.async_create_issue(
self.hass,
DOMAIN,
RESTART_REQUIRED_ISSUE_ID,
is_fixable=True, # <-- IMPORTANT: show "Fix" button
is_persistent=False,
severity=ir.IssueSeverity.WARNING,
translation_key=RESTART_REQUIRED_ISSUE_ID,
)
except Exception:
_LOGGER.debug("Failed to create restart required issue", exc_info=True)
def _clear_restart_required_issue(self) -> None:
"""Remove restart required issue after HA restarted."""
try:
if hasattr(ir, "async_delete_issue"):
ir.async_delete_issue(self.hass, DOMAIN, RESTART_REQUIRED_ISSUE_ID)
elif hasattr(ir, "async_remove_issue"):
ir.async_remove_issue(self.hass, DOMAIN, RESTART_REQUIRED_ISSUE_ID)
except Exception:
_LOGGER.debug("Failed to clear restart required issue", exc_info=True)
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)
def get_installed(self, repo_id: str) -> dict[str, Any] | None:
"""Return cached installation info for a repo_id (no I/O)."""
data = (self._installed_cache or {}).get(repo_id)
return data if isinstance(data, dict) else None
async def refresh(self) -> None:
index_repos, refresh_seconds = await self._load_index_repos()
self.refresh_seconds = refresh_seconds
custom_repos = await self.storage.list_custom_repos()
merged: dict[str, RepoItem] = {}
for item in index_repos:
merged[item.id] = item
for c in custom_repos:
merged[c.id] = RepoItem(
id=c.id,
name=(c.name or c.url),
url=c.url,
source="custom",
) )
self._in_progress = True for r in merged.values():
self.async_write_ha_state() r.provider = detect_provider(r.url)
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)
async def process_one(r: RepoItem) -> None:
async with sem:
info: RepoInfo = await fetch_repo_info(self.hass, r.url)
r.provider = info.provider or r.provider
r.owner = info.owner or r.owner
r.provider_repo_name = info.repo_name
r.provider_description = info.description
r.default_branch = info.default_branch or r.default_branch
r.latest_version = info.latest_version
r.latest_version_source = info.latest_version_source
md: RepoMetadata = await fetch_repo_metadata(self.hass, r.url, r.default_branch)
r.meta_source = md.source
r.meta_name = md.name
r.meta_description = md.description
r.meta_category = md.category
r.meta_author = md.author
r.meta_maintainer = md.maintainer
has_user_or_index_name = bool(r.name) and (r.name != r.url) and (not str(r.name).startswith("http"))
if r.meta_name:
r.name = r.meta_name
elif not has_user_or_index_name and r.provider_repo_name:
r.name = r.provider_repo_name
elif not r.name:
r.name = r.url
await asyncio.gather(*(process_one(r) for r in merged.values()), return_exceptions=True)
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: try:
await self._core.update_repo(self._repo_id) raw = await self._fetch_store_text(url)
finally:
self._in_progress = False
self.async_write_ha_state()
# 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
@callback except Exception as e:
def _sync_entities( raise BCSError(f"Failed fetching store index: {e}") from e
core: BCSCore,
existing: dict[str, BCSRepoUpdateEntity],
async_add_entities: AddEntitiesCallback,
) -> None:
"""Ensure there is one update entity per installed repo."""
installed_map = getattr(core, "_installed_cache", {}) or {}
new_entities: list[BCSRepoUpdateEntity] = []
for repo_id, data in installed_map.items(): # Diagnostics
if not isinstance(data, dict): b = raw.encode("utf-8", errors="replace")
continue h = hashlib.sha256(b).hexdigest()[:12]
if repo_id in existing: self.last_index_url = url
continue self.last_index_bytes = len(b)
self.last_index_hash = h
self.last_index_loaded_at = time.time()
ent = BCSRepoUpdateEntity(core, repo_id) _LOGGER.info(
existing[repo_id] = ent "BCS index loaded: url=%s bytes=%s sha=%s",
new_entities.append(ent) self.last_index_url,
self.last_index_bytes,
self.last_index_hash,
)
if new_entities: try:
async_add_entities(new_entities) data = ha_yaml.parse_yaml(raw)
if not isinstance(data, dict):
raise BCSError("store.yaml must be a mapping")
for ent in existing.values(): refresh_seconds = int(data.get("refresh_seconds", 300))
ent.async_write_ha_state() repos = data.get("repos", [])
if not isinstance(repos, list):
raise BCSError("store.yaml 'repos' must be a list")
items: list[RepoItem] = []
for i, r in enumerate(repos):
if not isinstance(r, dict):
continue
repo_url = str(r.get("url", "")).strip()
if not repo_url:
continue
name = str(r.get("name") or repo_url).strip()
async def async_setup_platform( items.append(
hass: HomeAssistant, RepoItem(
config, id=f"index:{i}",
async_add_entities: AddEntitiesCallback, name=name,
discovery_info=None, url=repo_url,
): source="index",
"""Set up BCS update entities.""" )
core: BCSCore | None = hass.data.get(DOMAIN) )
if not core:
_LOGGER.debug("BCS core not available, skipping update platform setup")
return
entities: dict[str, BCSRepoUpdateEntity] = {} _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
_sync_entities(core, entities, async_add_entities) async def add_custom_repo(self, url: str, name: str | None) -> CustomRepo:
url = str(url or "").strip()
if not url:
raise BCSError("Missing url")
@callback c = await self.storage.add_custom_repo(url, name)
def _handle_update() -> None: await self.full_refresh(source="custom_repo_add")
_sync_entities(core, entities, async_add_entities) return c
async_dispatcher_connect(hass, SIGNAL_UPDATED, _handle_update) async def remove_custom_repo(self, repo_id: str) -> None:
await self.storage.remove_custom_repo(repo_id)
await self.full_refresh(source="custom_repo_remove")
async def list_custom_repos(self) -> list[CustomRepo]:
return await self.storage.list_custom_repos()
def list_repos_public(self) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
installed_map: dict[str, Any] = getattr(self, "_installed_cache", {}) or {}
if not isinstance(installed_map, dict):
installed_map = {}
for r in self.repos.values():
inst = installed_map.get(r.id)
installed = bool(inst)
installed_domains: list[str] = []
installed_version: str | None = None
installed_manifest_version: str | None = None
if isinstance(inst, dict):
d = inst.get("domains") or []
if isinstance(d, list):
installed_domains = [str(x) for x in d if str(x).strip()]
v = inst.get("installed_version")
installed_version = str(v) if v is not None else None
mv = inst.get("installed_manifest_version")
installed_manifest_version = str(mv) if mv is not None else None
out.append(
{
"id": r.id,
"name": r.name,
"url": r.url,
"source": r.source,
"owner": r.owner,
"provider": r.provider,
"repo_name": r.provider_repo_name,
"description": r.provider_description or r.meta_description,
"default_branch": r.default_branch,
"latest_version": r.latest_version,
"latest_version_source": r.latest_version_source,
"category": r.meta_category,
"meta_author": r.meta_author,
"meta_maintainer": r.meta_maintainer,
"meta_source": r.meta_source,
"installed": installed,
"installed_version": installed_version,
"installed_manifest_version": installed_manifest_version,
"installed_domains": installed_domains,
}
)
return out
async def fetch_readme_markdown(self, repo_id: str) -> str | None:
repo = self.get_repo(repo_id)
if not repo:
return None
return await fetch_readme_markdown(
self.hass,
repo.url,
provider=repo.provider,
default_branch=repo.default_branch,
)
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()
if repo.default_branch and str(repo.default_branch).strip():
return str(repo.default_branch).strip()
return "main"
def _build_zip_url(self, repo_url: str, ref: str) -> str:
ref = (ref or "").strip()
if not ref:
raise BCSInstallError("Missing ref for ZIP download")
u = urlparse(repo_url.rstrip("/"))
host = (u.netloc or "").lower()
parts = [p for p in u.path.strip("/").split("/") if p]
if len(parts) < 2:
raise BCSInstallError("Invalid repository URL (missing owner/repo)")
owner = parts[0]
repo = parts[1]
if repo.endswith(".git"):
repo = repo[:-4]
if "github.com" in host:
return f"https://codeload.github.com/{owner}/{repo}/zip/{ref}"
if "gitlab" in host:
base = f"{u.scheme}://{u.netloc}"
path = u.path.strip("/")
if path.endswith(".git"):
path = path[:-4]
return f"{base}/{path}/-/archive/{ref}/{repo}-{ref}.zip"
base = f"{u.scheme}://{u.netloc}"
path = u.path.strip("/")
if path.endswith(".git"):
path = path[:-4]
return f"{base}/{path}/archive/{ref}.zip"
async def _download_zip(self, url: str, dest: Path) -> None:
session = async_get_clientsession(self.hass)
headers = {
"User-Agent": "BahmcloudStore (Home Assistant)",
"Cache-Control": "no-cache, no-store, max-age=0",
"Pragma": "no-cache",
}
async with session.get(url, timeout=120, headers=headers) as resp:
if resp.status != 200:
raise BCSInstallError(f"zip_url returned {resp.status}")
data = await resp.read()
await self.hass.async_add_executor_job(dest.write_bytes, data)
async def _extract_zip(self, zip_path: Path, extract_dir: Path) -> None:
def _extract() -> None:
with zipfile.ZipFile(zip_path, "r") as zf:
zf.extractall(extract_dir)
await self.hass.async_add_executor_job(_extract)
@staticmethod
def _find_custom_components_root(extract_root: Path) -> Path | None:
direct = extract_root / "custom_components"
if direct.exists() and direct.is_dir():
return direct
for child in extract_root.iterdir():
candidate = child / "custom_components"
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:
self._backup_root.mkdir(parents=True, exist_ok=True)
await self.hass.async_add_executor_job(_mkdir)
async def _backup_domain(self, domain: str) -> Path | None:
"""Backup an existing domain folder.
Returns the created backup path, or None if the domain folder does not exist.
"""
dest_root = Path(self.hass.config.path("custom_components"))
target = dest_root / domain
if not target.exists() or not target.is_dir():
return None
await self._ensure_backup_root()
ts = time.strftime("%Y%m%d_%H%M%S")
domain_root = self._backup_root / domain
backup_path = domain_root / ts
def _do_backup() -> None:
domain_root.mkdir(parents=True, exist_ok=True)
if backup_path.exists():
shutil.rmtree(backup_path, ignore_errors=True)
shutil.copytree(target, backup_path, dirs_exist_ok=True)
# Retention: keep only the newest N backups per domain.
try:
backups = [p for p in domain_root.iterdir() if p.is_dir()]
backups.sort(key=lambda p: p.name, reverse=True)
for old in backups[self._backup_keep_per_domain :]:
shutil.rmtree(old, ignore_errors=True)
except Exception:
# Never fail install/update because of retention cleanup.
pass
await self.hass.async_add_executor_job(_do_backup)
_LOGGER.info("BCS backup created: domain=%s path=%s", domain, backup_path)
return backup_path
async def _restore_domain_from_backup(self, domain: str, backup_path: Path) -> None:
"""Restore a domain folder from a backup."""
dest_root = Path(self.hass.config.path("custom_components"))
target = dest_root / domain
def _restore() -> None:
if not backup_path.exists() or not backup_path.is_dir():
return
if target.exists():
shutil.rmtree(target, ignore_errors=True)
shutil.copytree(backup_path, target, dirs_exist_ok=True)
await self.hass.async_add_executor_job(_restore)
_LOGGER.info("BCS rollback applied: domain=%s from=%s", domain, backup_path)
async def _copy_domain_dir(self, src_domain_dir: Path, domain: str) -> None:
dest_root = Path(self.hass.config.path("custom_components"))
target = dest_root / domain
tmp_target = dest_root / f".bcs_tmp_{domain}_{int(time.time())}"
def _copy() -> None:
if tmp_target.exists():
shutil.rmtree(tmp_target, ignore_errors=True)
shutil.copytree(src_domain_dir, tmp_target, dirs_exist_ok=True)
if target.exists():
shutil.rmtree(target, ignore_errors=True)
tmp_target.rename(target)
await self.hass.async_add_executor_job(_copy)
async def _read_installed_manifest_version(self, domain: str) -> str | None:
def _read() -> str | None:
try:
p = Path(self.hass.config.path("custom_components", domain, "manifest.json"))
if not p.exists():
return None
data = json.loads(p.read_text(encoding="utf-8"))
v = data.get("version")
return str(v) if v else None
except Exception:
return None
return await self.hass.async_add_executor_job(_read)
async def _refresh_installed_cache(self) -> None:
"""Refresh installed cache and reconcile with filesystem.
If a user manually deletes a domain folder under /config/custom_components,
we automatically remove the installed flag from our storage so the Store UI
does not show stale "installed" state.
"""
try:
items = await self.storage.list_installed_repos()
cache: dict[str, Any] = {}
# Determine which installed repos still exist on disk.
cc_root = Path(self.hass.config.path("custom_components"))
to_remove: list[str] = []
for it in items:
domains = [str(d) for d in (it.domains or []) if str(d).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 not present:
to_remove.append(it.repo_id)
continue
cache[it.repo_id] = {
"installed": True,
"domains": domains,
"installed_version": it.installed_version,
"installed_manifest_version": it.installed_manifest_version,
"ref": it.ref,
"installed_at": it.installed_at,
}
# Remove stale installed entries from storage.
for rid in to_remove:
try:
await self.storage.remove_installed_repo(rid)
_LOGGER.info("BCS reconcile: removed stale installed repo_id=%s", rid)
except Exception:
_LOGGER.debug("BCS reconcile: failed removing stale repo_id=%s", rid, exc_info=True)
self._installed_cache = cache
except Exception:
self._installed_cache = {}
async def uninstall_repo(self, repo_id: str) -> dict[str, Any]:
"""Uninstall a repository by deleting its installed domains and clearing storage."""
async with self._install_lock:
inst = await self.storage.get_installed_repo(repo_id)
if not inst:
# Already uninstalled.
await self._refresh_installed_cache()
self.signal_updated()
return {"ok": True, "repo_id": repo_id, "removed": [], "restart_required": False}
cc_root = Path(self.hass.config.path("custom_components"))
removed: list[str] = []
def _remove_dir(path: Path) -> None:
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)
await self.storage.remove_installed_repo(repo_id)
await self._refresh_installed_cache()
# Show restart required in Settings.
if removed:
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)}
async def install_repo(self, repo_id: str) -> 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)
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)
installed_domains: list[str] = []
backups: dict[str, Path] = {}
created_new: set[str] = set()
try:
with tempfile.TemporaryDirectory(prefix="bcs_install_") as td:
tmp = Path(td)
zip_path = tmp / "repo.zip"
extract_dir = tmp / "extract"
extract_dir.mkdir(parents=True, exist_ok=True)
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")
dest_root = Path(self.hass.config.path("custom_components"))
for domain_dir in cc_root.iterdir():
if not domain_dir.is_dir():
continue
manifest = domain_dir / "manifest.json"
if not manifest.exists():
continue
domain = domain_dir.name
target = dest_root / domain
# Backup only if we are going to overwrite an existing domain.
if target.exists() and target.is_dir():
bkp = await self._backup_domain(domain)
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])
installed_version = ref
await self.storage.set_installed_repo(
repo_id=repo_id,
url=repo.url,
domains=installed_domains,
installed_version=installed_version,
installed_manifest_version=installed_manifest_version,
ref=ref,
)
await self._refresh_installed_cache()
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_version,
installed_manifest_version,
)
self.signal_updated()
return {
"ok": True,
"repo_id": repo_id,
"domains": installed_domains,
"installed_version": installed_version,
"installed_manifest_version": installed_manifest_version,
"restart_required": True,
}
except Exception as e:
# Roll back any domains we touched.
_LOGGER.error("BCS install failed, attempting rollback: repo_id=%s error=%s", repo_id, e)
dest_root = Path(self.hass.config.path("custom_components"))
# Restore backed-up domains.
for domain, bkp in backups.items():
try:
await self._restore_domain_from_backup(domain, bkp)
except Exception:
_LOGGER.debug("BCS rollback failed for domain=%s", domain, exc_info=True)
# Remove newly created domains if the install did not complete.
for domain in created_new:
try:
target = dest_root / domain
def _rm() -> None:
if target.exists() and target.is_dir():
shutil.rmtree(target, ignore_errors=True)
await self.hass.async_add_executor_job(_rm)
except Exception:
_LOGGER.debug("BCS cleanup failed for new domain=%s", domain, exc_info=True)
# Re-raise as install error for clean API response.
if isinstance(e, BCSInstallError):
raise
raise BCSInstallError(str(e)) from e
async def update_repo(self, repo_id: str) -> dict[str, Any]:
_LOGGER.info("BCS update started: repo_id=%s", repo_id)
return await self.install_repo(repo_id)
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", "domain": "bahmcloud_store",
"name": "Bahmcloud Store", "name": "Bahmcloud Store",
"version": "0.5.3", "version": "0.5.11",
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store", "documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
"platforms": ["update"], "platforms": ["update"],
"requirements": [], "requirements": [],

View File

@@ -29,6 +29,7 @@ class BahmcloudStorePanel extends HTMLElement {
this._installingRepoId = null; this._installingRepoId = null;
this._updatingRepoId = null; this._updatingRepoId = null;
this._uninstallingRepoId = null;
this._restartRequired = false; this._restartRequired = false;
this._lastActionMsg = null; this._lastActionMsg = null;
} }
@@ -141,6 +142,36 @@ class BahmcloudStorePanel extends HTMLElement {
} }
} }
async _uninstallRepo(repoId) {
if (!repoId) return;
if (this._installingRepoId || this._updatingRepoId || this._uninstallingRepoId) return;
const r = this._repoById(repoId);
const name = this._safeText(r?.name) || repoId;
const ok = window.confirm(`Uninstall "${name}"?\n\nThis will delete the integration folder(s) from /config/custom_components. A restart will be required.`);
if (!ok) return;
this._uninstallingRepoId = repoId;
this._lastActionMsg = null;
this.requestUpdate();
try {
const resp = await this._hass.callApi("post", `bcs/uninstall?repo_id=${encodeURIComponent(repoId)}`, {});
if (resp && resp.ok) {
this._restartRequired = !!resp.restart_required;
this._lastActionMsg = "Uninstalled. Restart required.";
} else {
this._lastActionMsg = (resp && resp.message) ? String(resp.message) : "Uninstall failed.";
}
} catch (e) {
this._lastActionMsg = e && e.message ? e.message : "Uninstall failed.";
} finally {
this._uninstallingRepoId = null;
await this._load();
}
}
async _restartHA() { async _restartHA() {
if (!this._hass) return; if (!this._hass) return;
try { try {
@@ -436,15 +467,15 @@ class BahmcloudStorePanel extends HTMLElement {
<div class="mobilebar"> <div class="mobilebar">
<div class="left"> <div class="left">
<div class="iconbtn" id="menuBtn" title="Menu"></div> <div class="iconbtn" id="menuBtn" title="Menu"><EFBFBD></div>
<div class="iconbtn" id="backBtn" title="Back"></div> <div class="iconbtn" id="backBtn" title="Back"><EFBFBD></div>
<div> <div>
<div style="font-weight:700;">Bahmcloud Store</div> <div style="font-weight:700;">Bahmcloud Store</div>
<div class="muted small" id="subtitle">Store</div> <div class="muted small" id="subtitle">Store</div>
</div> </div>
</div> </div>
<div class="right"> <div class="right">
<div class="iconbtn" id="refreshBtn" title="Refresh"></div> <div class="iconbtn" id="refreshBtn" title="Refresh"><EFBFBD></div>
</div> </div>
</div> </div>
@@ -506,7 +537,7 @@ class BahmcloudStorePanel extends HTMLElement {
: ""; : "";
if (this._loading) { if (this._loading) {
content.innerHTML = `${err}<div class="card">Loading</div>`; content.innerHTML = `${err}<div class="card">Loading<EFBFBD></div>`;
fabs.innerHTML = ""; fabs.innerHTML = "";
return; return;
} }
@@ -608,7 +639,7 @@ class BahmcloudStorePanel extends HTMLElement {
if (updateAvailable) badges.push("Update"); if (updateAvailable) badges.push("Update");
const badgeHtml = badges.length const badgeHtml = badges.length
? `<div class="badge">${this._esc(badges.join(" · "))}</div>` ? `<div class="badge">${this._esc(badges.join(" "))}</div>`
: `<div class="badge">${this._esc(this._safeText(r?.provider || "repo"))}</div>`; : `<div class="badge">${this._esc(this._safeText(r?.provider || "repo"))}</div>`;
return ` return `
@@ -628,7 +659,7 @@ class BahmcloudStorePanel extends HTMLElement {
return ` return `
<div class="filters"> <div class="filters">
<input id="q" placeholder="Search" value="${this._esc(this._search)}" /> <input id="q" placeholder="Search<EFBFBD>" value="${this._esc(this._search)}" />
<select id="cat"> <select id="cat">
<option value="all">All categories</option> <option value="all">All categories</option>
${categories.map((c) => `<option value="${this._esc(c)}" ${this._category === c ? "selected" : ""}>${this._esc(c)}</option>`).join("")} ${categories.map((c) => `<option value="${this._esc(c)}" ${this._category === c ? "selected" : ""}>${this._esc(c)}</option>`).join("")}
@@ -640,7 +671,7 @@ class BahmcloudStorePanel extends HTMLElement {
</select> </select>
</div> </div>
<div class="muted small">Version: ${this._esc(this._data.version || "-")} · Repositories: ${repos.length}</div> <div class="muted small">Version: ${this._esc(this._data.version || "-")} Repositories: ${repos.length}</div>
<div class="grid" style="margin-top:12px;"> <div class="grid" style="margin-top:12px;">
${cards || `<div class="card">No repositories found.</div>`} ${cards || `<div class="card">No repositories found.</div>`}
@@ -713,7 +744,7 @@ class BahmcloudStorePanel extends HTMLElement {
].filter(Boolean); ].filter(Boolean);
const readmeBlock = this._readmeLoading const readmeBlock = this._readmeLoading
? `<div class="card">Loading README</div>` ? `<div class="card">Loading README<EFBFBD></div>`
: this._readmeText : this._readmeText
? ` ? `
<div class="card"> <div class="card">
@@ -748,12 +779,14 @@ class BahmcloudStorePanel extends HTMLElement {
const busyInstall = this._installingRepoId === repoId; const busyInstall = this._installingRepoId === repoId;
const busyUpdate = this._updatingRepoId === repoId; const busyUpdate = this._updatingRepoId === repoId;
const busy = busyInstall || busyUpdate; const busyUninstall = this._uninstallingRepoId === repoId;
const busy = busyInstall || busyUpdate || busyUninstall;
const updateAvailable = installed && !!latestVersion && (!installedVersion || latestVersion !== installedVersion); const updateAvailable = installed && !!latestVersion && (!installedVersion || latestVersion !== installedVersion);
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<EFBFBD>" : 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<EFBFBD>" : updateAvailable ? "Update" : "Up to date"}</button>`;
const uninstallBtn = `<button id="btnUninstall" ${!installed || busy ? "disabled" : ""}>${busyUninstall ? "Uninstalling鈥<67>" : "Uninstall"}</button>`;
const restartHint = this._restartRequired const restartHint = this._restartRequired
? ` ? `
@@ -777,7 +810,7 @@ class BahmcloudStorePanel extends HTMLElement {
<div> <div>
<div><strong style="font-size:16px;">${this._esc(name)}</strong></div> <div><strong style="font-size:16px;">${this._esc(name)}</strong></div>
<div class="muted">${this._esc(desc)}</div> <div class="muted">${this._esc(desc)}</div>
<div class="muted small" style="margin-top:8px;">${this._esc(infoBits.join(" · "))}</div> <div class="muted small" style="margin-top:8px;">${this._esc(infoBits.join(" "))}</div>
<div class="muted small" style="margin-top:8px;"> <div class="muted small" style="margin-top:8px;">
<a href="${this._esc(url)}" target="_blank" rel="noreferrer">Open repository</a> <a href="${this._esc(url)}" target="_blank" rel="noreferrer">Open repository</a>
</div> </div>
@@ -805,6 +838,7 @@ class BahmcloudStorePanel extends HTMLElement {
<div class="row" style="margin-top:14px; gap:10px; flex-wrap:wrap;"> <div class="row" style="margin-top:14px; gap:10px; flex-wrap:wrap;">
${installBtn} ${installBtn}
${updateBtn} ${updateBtn}
${uninstallBtn}
</div> </div>
${restartHint} ${restartHint}
@@ -820,6 +854,7 @@ class BahmcloudStorePanel extends HTMLElement {
// Always wire action buttons (even if README is already loaded) // Always wire action buttons (even if README is already loaded)
const btnInstall = root.getElementById("btnInstall"); const btnInstall = root.getElementById("btnInstall");
const btnUpdate = root.getElementById("btnUpdate"); const btnUpdate = root.getElementById("btnUpdate");
const btnUninstall = root.getElementById("btnUninstall");
const btnRestart = root.getElementById("btnRestart"); const btnRestart = root.getElementById("btnRestart");
if (btnInstall) { if (btnInstall) {
@@ -836,6 +871,13 @@ class BahmcloudStorePanel extends HTMLElement {
}); });
} }
if (btnUninstall) {
btnUninstall.addEventListener("click", () => {
if (btnUninstall.disabled) return;
if (this._detailRepoId) this._uninstallRepo(this._detailRepoId);
});
}
if (btnRestart) { if (btnRestart) {
btnRestart.addEventListener("click", () => this._restartHA()); btnRestart.addEventListener("click", () => this._restartHA());
} }
@@ -848,7 +890,7 @@ class BahmcloudStorePanel extends HTMLElement {
mount.innerHTML = this._readmeHtml; mount.innerHTML = this._readmeHtml;
this._postprocessRenderedMarkdown(mount); this._postprocessRenderedMarkdown(mount);
} else { } else {
mount.innerHTML = `<div class="muted">Rendered HTML not available. Use “Show raw Markdown.</div>`; mount.innerHTML = `<div class="muted">Rendered HTML not available. Use 鈥淪how raw Markdown<EFBFBD>.</div>`;
} }
} else { } else {
mount.innerHTML = ""; mount.innerHTML = "";
@@ -883,10 +925,10 @@ class BahmcloudStorePanel extends HTMLElement {
return ` return `
<div class="fabs"> <div class="fabs">
<button class="fabbtn primary" id="fabOpen" title="Open repository"></button> <button class="fabbtn primary" id="fabOpen" title="Open repository"><EFBFBD></button>
<button class="fabbtn" id="fabReload" title="Reload README"></button> <button class="fabbtn" id="fabReload" title="Reload README"><EFBFBD></button>
<button class="fabbtn" id="fabInstall" title="${installDisabled ? (installed ? "Already installed" : "Installing") : "Install"}" ${installDisabled ? "disabled" : ""}></button> <button class="fabbtn" id="fabInstall" title="${installDisabled ? (installed ? "Already installed" : "Installing<EFBFBD>") : "Install"}" ${installDisabled ? "disabled" : ""}><EFBFBD></button>
<button class="fabbtn" id="fabUpdate" title="${updateDisabled ? (!installed ? "Not installed" : "No update available") : "Update"}" ${updateDisabled ? "disabled" : ""}></button> <button class="fabbtn" id="fabUpdate" title="${updateDisabled ? (!installed ? "Not installed" : "No update available") : "Update"}" ${updateDisabled ? "disabled" : ""}><EFBFBD></button>
<button class="fabbtn" id="fabInfo" title="About">i</button> <button class="fabbtn" id="fabInfo" title="About">i</button>
</div> </div>
`; `;

View File

@@ -15,23 +15,25 @@ class BahmcloudStorePanel extends HTMLElement {
this._search = ""; this._search = "";
this._category = "all"; this._category = "all";
this._provider = "all"; // all|github|gitea|gitlab|other|custom this._filter = "all"; // all|installed|not_installed|updates|custom
this._sort = "az"; // az|updates_first|installed_first
this._detailRepoId = null; this._detailRepoId = null;
this._detailRepo = null; this._detailRepo = null;
this._readmeLoading = false; this._readmeLoading = false;
this._readmeText = null; this._readmeText = null;
this._readmeHtml = null; this._readmeHtml = null;
this._readmeError = null; this._readmeError = null;
// Manual refresh UX state // README UX (E2)
this._refreshing = false; this._readmeExpanded = false;
this._status = ""; this._readmeCanToggle = false;
this._refreshing = false;
// Install/Update UX
this._installingRepoId = null; this._installingRepoId = null;
this._updatingRepoId = null; this._updatingRepoId = null;
this._uninstallingRepoId = null;
this._restartRequired = false; this._restartRequired = false;
this._lastActionMsg = null; this._lastActionMsg = null;
} }
@@ -56,7 +58,6 @@ class BahmcloudStorePanel extends HTMLElement {
const data = await this._hass.callApi("get", "bcs"); const data = await this._hass.callApi("get", "bcs");
this._data = data; this._data = data;
// keep detail fresh
if (this._view === "detail" && this._detailRepoId && Array.isArray(data?.repos)) { if (this._view === "detail" && this._detailRepoId && Array.isArray(data?.repos)) {
const fresh = data.repos.find((r) => this._safeId(r?.id) === this._detailRepoId); const fresh = data.repos.find((r) => this._safeId(r?.id) === this._detailRepoId);
if (fresh) this._detailRepo = fresh; if (fresh) this._detailRepo = fresh;
@@ -75,19 +76,16 @@ class BahmcloudStorePanel extends HTMLElement {
this._refreshing = true; this._refreshing = true;
this._error = null; this._error = null;
this._status = "Refreshing…"; this._loading = true;
this._update(); this._update();
try { try {
const resp = await this._hass.callApi("post", "bcs?action=refresh", {}); const resp = await this._hass.callApi("post", "bcs?action=refresh", {});
if (!resp?.ok) { if (!resp?.ok) {
this._status = ""; const msg = this._safeText(resp?.message) || "Refresh failed.";
this._error = this._safeText(resp?.message) || "Refresh failed."; this._error = msg;
} else {
this._status = "Refresh done.";
} }
} catch (e) { } catch (e) {
this._status = "";
this._error = e?.message ? String(e.message) : String(e); this._error = e?.message ? String(e.message) : String(e);
} finally { } finally {
this._refreshing = false; this._refreshing = false;
@@ -148,6 +146,35 @@ class BahmcloudStorePanel extends HTMLElement {
} }
} }
async _uninstallRepo(repoId) {
if (!this._hass) return;
if (!repoId) return;
if (this._installingRepoId || this._updatingRepoId || this._uninstallingRepoId) return;
const ok = window.confirm("Really uninstall this repository? This will remove its files from /config/custom_components and requires a restart.");
if (!ok) return;
this._uninstallingRepoId = repoId;
this._error = null;
this._lastActionMsg = null;
this._update();
try {
const resp = await this._hass.callApi("post", `bcs/uninstall?repo_id=${encodeURIComponent(repoId)}`, {});
if (!resp?.ok) {
this._error = this._safeText(resp?.message) || "Uninstall failed.";
} else {
this._restartRequired = !!resp.restart_required;
this._lastActionMsg = "Uninstall finished. Restart required.";
}
} catch (e) {
this._error = e?.message ? String(e.message) : String(e);
} finally {
this._uninstallingRepoId = null;
await this._load();
}
}
async _restartHA() { async _restartHA() {
if (!this._hass) return; if (!this._hass) return;
try { try {
@@ -178,7 +205,7 @@ class BahmcloudStorePanel extends HTMLElement {
this._readmeText = null; this._readmeText = null;
this._readmeHtml = null; this._readmeHtml = null;
this._readmeError = null; this._readmeError = null;
this._status = ""; this._readmeExpanded = false;
this._update(); this._update();
return; return;
} }
@@ -243,10 +270,8 @@ class BahmcloudStorePanel extends HTMLElement {
this._readmeText = null; this._readmeText = null;
this._readmeHtml = null; this._readmeHtml = null;
this._readmeError = null; this._readmeError = null;
this._readmeExpanded = false;
this._status = ""; this._readmeCanToggle = false;
this._restartRequired = false;
this._lastActionMsg = null;
this._update(); this._update();
this._loadReadme(repoId); this._loadReadme(repoId);
@@ -264,15 +289,23 @@ class BahmcloudStorePanel extends HTMLElement {
if (resp?.ok && typeof resp.readme === "string" && resp.readme.trim()) { if (resp?.ok && typeof resp.readme === "string" && resp.readme.trim()) {
this._readmeText = resp.readme; this._readmeText = resp.readme;
this._readmeHtml = typeof resp.html === "string" && resp.html.trim() ? resp.html : null; this._readmeHtml = typeof resp.html === "string" && resp.html.trim() ? resp.html : null;
const lines = resp.readme.split(/\r?\n/).length;
this._readmeCanToggle = lines > 24 || resp.readme.length > 1600;
this._readmeExpanded = !this._readmeCanToggle;
} else { } else {
this._readmeText = null; this._readmeText = null;
this._readmeHtml = null; this._readmeHtml = null;
this._readmeError = this._safeText(resp?.message) || "README not found."; this._readmeError = this._safeText(resp?.message) || "README not found.";
this._readmeCanToggle = false;
this._readmeExpanded = false;
} }
} catch (e) { } catch (e) {
this._readmeText = null; this._readmeText = null;
this._readmeHtml = null; this._readmeHtml = null;
this._readmeError = e?.message ? String(e.message) : String(e); this._readmeError = e?.message ? String(e.message) : String(e);
this._readmeCanToggle = false;
this._readmeExpanded = false;
} finally { } finally {
this._readmeLoading = false; this._readmeLoading = false;
this._update(); this._update();
@@ -284,7 +317,7 @@ class BahmcloudStorePanel extends HTMLElement {
root.innerHTML = ` root.innerHTML = `
<style> <style>
:host { display:block; min-height:100%; --bcs-accent:#1E88E5; } :host { display:block; min-height:100%; --bcs-accent:#1E88E5; max-width:100%; overflow-x:hidden; }
.mobilebar{ .mobilebar{
position:sticky; top:0; z-index:50; position:sticky; top:0; z-index:50;
@@ -299,13 +332,15 @@ class BahmcloudStorePanel extends HTMLElement {
.iconbtn{ .iconbtn{
width:40px; height:40px; border-radius:14px; width:40px; height:40px; border-radius:14px;
display:flex; align-items:center; justify-content:center; display:flex; align-items:center; justify-content:center;
border:1px solid var(--divider-color); /* Ensure icon visibility on app header (light & dark modes) */
background: var(--card-background-color); color: var(--app-header-text-color, var(--primary-text-color));
border: 1px solid rgba(255,255,255,0.35);
background: rgba(255,255,255,0.16);
cursor:pointer; user-select:none; cursor:pointer; user-select:none;
} }
.iconbtn:hover{ filter:brightness(0.98); } .iconbtn:hover{ filter:brightness(0.98); }
.wrap{ max-width:1200px; margin:0 auto; padding:16px; } .wrap{ max-width:1200px; margin:0 auto; padding:16px; overflow-x:hidden; }
.tabs{ display:flex; gap:10px; flex-wrap:wrap; margin:8px 0 16px; } .tabs{ display:flex; gap:10px; flex-wrap:wrap; margin:8px 0 16px; }
.tab{ .tab{
@@ -318,9 +353,12 @@ class BahmcloudStorePanel extends HTMLElement {
.grid{ display:grid; gap:12px; grid-template-columns: repeat(1, minmax(0, 1fr)); } .grid{ display:grid; gap:12px; grid-template-columns: repeat(1, minmax(0, 1fr)); }
@media (min-width: 900px){ .grid{ grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (min-width: 900px){ .grid{ grid-template-columns: repeat(2, minmax(0, 1fr)); } }
/* Prevent grid children from forcing horizontal overflow (mobile) */
.grid > *{ min-width:0; }
.grid2{ display:grid; gap:12px; grid-template-columns: 1fr; } .grid2{ display:grid; gap:12px; grid-template-columns: 1fr; }
@media (min-width: 1024px){ .grid2{ grid-template-columns: 1.2fr .8fr; } } @media (min-width: 1024px){ .grid2{ grid-template-columns: 1.2fr .8fr; } }
.grid2 > *{ min-width:0; }
.card{ .card{
padding:14px 14px; padding:14px 14px;
@@ -328,12 +366,12 @@ class BahmcloudStorePanel extends HTMLElement {
background: var(--card-background-color); background: var(--card-background-color);
border:1px solid var(--divider-color); border:1px solid var(--divider-color);
box-shadow: 0 1px 0 rgba(0,0,0,.04); box-shadow: 0 1px 0 rgba(0,0,0,.04);
overflow:hidden;
} }
.row{ display:flex; align-items:flex-start; justify-content:space-between; gap:12px; } .row{ display:flex; align-items:flex-start; justify-content:space-between; gap:12px; flex-wrap:wrap; }
.muted{ color: var(--secondary-text-color); } .muted{ color: var(--secondary-text-color); overflow-wrap:anywhere; word-break:break-word; }
.small{ font-size: 12px; } .small{ font-size: 12px; }
.badge{ .badge{
padding:6px 10px; padding:6px 10px;
border-radius:999px; border-radius:999px;
@@ -341,7 +379,10 @@ class BahmcloudStorePanel extends HTMLElement {
background: rgba(30,136,229,.06); background: rgba(30,136,229,.06);
color: var(--primary-text-color); color: var(--primary-text-color);
font-size: 12px; font-size: 12px;
white-space:nowrap; white-space:normal;
max-width:55%;
overflow:hidden;
text-overflow:ellipsis;
} }
.filters{ display:flex; gap:10px; flex-wrap:wrap; margin-bottom:12px; } .filters{ display:flex; gap:10px; flex-wrap:wrap; margin-bottom:12px; }
@@ -380,36 +421,6 @@ class BahmcloudStorePanel extends HTMLElement {
background: rgba(255, 82, 82, .08); background: rgba(255, 82, 82, .08);
} }
.fabs{
position: fixed;
right: 16px;
bottom: 16px;
display:flex;
flex-direction:column;
gap:10px;
z-index: 60;
}
.fabbtn{
width:54px; height:54px;
border-radius:18px;
border:1px solid var(--divider-color);
background: var(--card-background-color);
display:flex; align-items:center; justify-content:center;
cursor:pointer;
box-shadow: 0 8px 18px rgba(0,0,0,.12);
user-select:none;
font-size: 18px;
padding: 0;
}
.fabbtn.primary{
border-color: rgba(30,136,229,.35);
background: rgba(30,136,229,.10);
}
.fabbtn:disabled{
opacity: .55;
cursor: not-allowed;
}
pre.readme{ pre.readme{
padding: 12px; padding: 12px;
border-radius: 14px; border-radius: 14px;
@@ -420,7 +431,10 @@ class BahmcloudStorePanel extends HTMLElement {
line-height: 1.4; line-height: 1.4;
} }
/* Markdown can contain very wide content (tables/images). Keep it within the viewport. */
.md{ overflow-wrap:anywhere; word-break:break-word; max-width:100%; min-width:0; overflow-x:auto; -webkit-overflow-scrolling:touch; }
.md :is(h1,h2,h3){ margin-top: 12px; } .md :is(h1,h2,h3){ margin-top: 12px; }
.md img{ max-width:100%; height:auto; }
.md code{ .md code{
padding: 2px 5px; padding: 2px 5px;
border-radius: 8px; border-radius: 8px;
@@ -437,14 +451,65 @@ class BahmcloudStorePanel extends HTMLElement {
.md table{ .md table{
width:100%; width:100%;
border-collapse: collapse; border-collapse: collapse;
overflow:auto; overflow-x:auto;
overflow-y:hidden;
-webkit-overflow-scrolling: touch;
display:block; display:block;
max-width:100%;
} }
.readmeWrap{
margin-top:12px;
max-width:100%;
border:1px solid var(--divider-color);
border-radius:14px;
padding:12px;
background: rgba(0,0,0,.02);
position:relative;
overflow:hidden;
}
.readmeWrap.collapsed{
max-height:260px;
}
@media (min-width: 1024px){
.readmeWrap.collapsed{ max-height:340px; }
}
.readmeWrap.collapsed::after{
content:"";
position:absolute;
left:0; right:0; bottom:0;
height:64px;
background: linear-gradient(to bottom, rgba(0,0,0,0), var(--card-background-color));
pointer-events:none;
}
.readmeWrap.expanded{
max-height:70vh;
overflow:auto;
-webkit-overflow-scrolling:touch;
}
.readmeActions{
display:flex; justify-content:flex-end;
margin-top:10px;
}
button.link{
border:1px solid transparent;
background: transparent;
color: var(--bcs-accent);
padding:8px 10px;
}
button.link:hover{
border-color: rgba(30,136,229,.25);
background: rgba(30,136,229,.06);
}
.md th, .md td{ .md th, .md td{
border: 1px solid var(--divider-color); border: 1px solid var(--divider-color);
padding: 8px; padding: 8px;
text-align:left; text-align:left;
max-width:100%;
overflow-wrap:anywhere;
word-break:break-word;
} }
</style> </style>
<div class="mobilebar"> <div class="mobilebar">
@@ -457,30 +522,39 @@ class BahmcloudStorePanel extends HTMLElement {
</div> </div>
</div> </div>
<div class="right"> <div class="right">
<button id="refreshBtn" class="primary" style="border-radius:14px; padding:8px 12px;">Refresh</button> <div class="iconbtn" id="refreshBtn" title="Refresh">⟳</div>
</div> </div>
</div> </div>
<div class="wrap"> <div class="wrap">
<div class="tabs"> <div class="tabs">
<div class="tab" id="tabStore">Store</div> <div class="tab" id="tabStore">Store</div>
<div class="tab" id="tabManage">Manage repositories</div> <div class="tab" id="tabManage">Manage</div>
<div class="tab" id="tabAbout">Settings / About</div> <div class="tab" id="tabAbout">About</div>
</div> </div>
<div id="content"></div> <div id="content"></div>
</div> </div>
<div id="fabs"></div>
`; `;
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());
root.getElementById("refreshBtn").addEventListener("click", () => this._refreshAll()); root.getElementById("refreshBtn").addEventListener("click", () => this._refreshAll());
root.getElementById("tabStore").addEventListener("click", () => { this._view = "store"; this._update(); }); root.getElementById("tabStore").addEventListener("click", () => {
root.getElementById("tabManage").addEventListener("click", () => { this._view = "manage"; this._update(); }); this._view = "store";
root.getElementById("tabAbout").addEventListener("click", () => { this._view = "about"; this._update(); }); this._update();
});
root.getElementById("tabManage").addEventListener("click", () => {
this._view = "manage";
this._update();
});
root.getElementById("tabAbout").addEventListener("click", () => {
this._view = "about";
this._update();
});
this._update(); this._update();
} }
@@ -489,6 +563,25 @@ class BahmcloudStorePanel extends HTMLElement {
const root = this.shadowRoot; const root = this.shadowRoot;
if (!root) return; if (!root) return;
// Preserve focus & cursor position for inputs that trigger a re-render (e.g. search).
// Without this, mobile browsers may drop focus after each keystroke.
const active = root.activeElement;
const restore = {
id: active && active.id ? String(active.id) : null,
start: null,
end: null,
};
try {
if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) {
if (typeof active.selectionStart === "number" && typeof active.selectionEnd === "number") {
restore.start = active.selectionStart;
restore.end = active.selectionEnd;
}
}
} catch (_) {
// ignore
}
const subtitle = root.getElementById("subtitle"); const subtitle = root.getElementById("subtitle");
if (subtitle) subtitle.textContent = this._view === "detail" ? "Details" : this._view[0].toUpperCase() + this._view.slice(1); if (subtitle) subtitle.textContent = this._view === "detail" ? "Details" : this._view[0].toUpperCase() + this._view.slice(1);
@@ -502,21 +595,19 @@ class BahmcloudStorePanel extends HTMLElement {
setActive("tabAbout", this._view === "about"); setActive("tabAbout", this._view === "about");
const content = root.getElementById("content"); const content = root.getElementById("content");
const fabs = root.getElementById("fabs"); if (!content) return;
if (!content || !fabs) return;
const err = this._error ? `<div class="err"><strong>Error:</strong> ${this._esc(this._error)}</div>` : ""; const err = this._error
const status = this._status ? `<div class="muted small" style="margin:10px 0;">${this._esc(this._status)}</div>` : ""; ? `<div class="err"><strong>Error:</strong> ${this._esc(this._error)}</div>`
: "";
if (this._loading) { if (this._loading) {
content.innerHTML = `${err}${status}<div class="card">Loading…</div>`; content.innerHTML = `${err}<div class="card">Loading…</div>`;
fabs.innerHTML = "";
return; return;
} }
if (!this._data?.ok) { if (!this._data?.ok) {
content.innerHTML = `${err}${status}<div class="card">No data. Please refresh.</div>`; content.innerHTML = `${err}<div class="card">No data. Please refresh.</div>`;
fabs.innerHTML = "";
return; return;
} }
@@ -526,14 +617,27 @@ class BahmcloudStorePanel extends HTMLElement {
else if (this._view === "about") html = this._renderAbout(); else if (this._view === "about") html = this._renderAbout();
else if (this._view === "detail") html = this._renderDetail(); else if (this._view === "detail") html = this._renderDetail();
content.innerHTML = `${err}${status}${html}`; content.innerHTML = `${err}${html}`;
fabs.innerHTML = this._view === "detail" ? this._renderFabs() : "";
if (this._view === "store") this._wireStore(); if (this._view === "store") this._wireStore();
if (this._view === "manage") this._wireManage(); if (this._view === "manage") this._wireManage();
if (this._view === "detail") { if (this._view === "detail") {
this._wireDetail(); this._wireDetail(); // now always wires buttons
this._wireFabs(); }
// Restore focus and cursor for the search field after re-render.
if (restore.id && this._view === "store") {
const el = root.getElementById(restore.id);
if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA")) {
try {
el.focus({ preventScroll: true });
if (restore.start !== null && restore.end !== null && typeof el.setSelectionRange === "function") {
el.setSelectionRange(restore.start, restore.end);
}
} catch (_) {
// ignore
}
}
} }
} }
@@ -556,19 +660,12 @@ class BahmcloudStorePanel extends HTMLElement {
} }
_asBoolStrict(v) { _asBoolStrict(v) {
// IMPORTANT: only treat literal true as installed
return v === true; return v === true;
} }
_renderStore() { _renderStore() {
const repos = Array.isArray(this._data.repos) ? this._data.repos : []; const repos = Array.isArray(this._data.repos) ? this._data.repos : [];
const categories = Array.from(
new Set(repos.map((r) => this._safeText(r?.category)).filter((c) => !!c))
).sort();
const providers = ["github", "gitlab", "gitea", "other"];
const filtered = repos const filtered = repos
.filter((r) => { .filter((r) => {
const name = (this._safeText(r?.name) || "").toLowerCase(); const name = (this._safeText(r?.name) || "").toLowerCase();
@@ -581,23 +678,52 @@ class BahmcloudStorePanel extends HTMLElement {
const cat = this._safeText(r?.category) || ""; const cat = this._safeText(r?.category) || "";
if (this._category !== "all" && this._category !== cat) return false; if (this._category !== "all" && this._category !== cat) return false;
const prov = this._safeText(r?.provider) || "other"; const latest = this._safeText(r?.latest_version);
if (this._provider !== "all") { const installed = this._asBoolStrict(r?.installed);
if (this._provider === "custom" && r?.source !== "custom") return false; const installedVersion = this._safeText(r?.installed_version);
if (this._provider !== "custom" && prov !== this._provider) return false; const updateAvailable = installed && !!latest && (!installedVersion || latest !== installedVersion);
}
if (this._filter === "installed" && !installed) return false;
if (this._filter === "not_installed" && installed) return false;
if (this._filter === "updates" && !updateAvailable) return false;
if (this._filter === "custom" && r?.source !== "custom") return false;
return true; return true;
}) })
.sort((a, b) => { .sort((a, b) => {
const an = (this._safeText(a?.name) || "").toLowerCase(); const an = (this._safeText(a?.name) || "").toLowerCase();
const bn = (this._safeText(b?.name) || "").toLowerCase(); const bn = (this._safeText(b?.name) || "").toLowerCase();
const alatest = this._safeText(a?.latest_version);
const ainstalled = this._asBoolStrict(a?.installed);
const ainstalledVersion = this._safeText(a?.installed_version);
const aupdate = ainstalled && !!alatest && (!ainstalledVersion || alatest !== ainstalledVersion);
const blatest = this._safeText(b?.latest_version);
const binstalled = this._asBoolStrict(b?.installed);
const binstalledVersion = this._safeText(b?.installed_version);
const bupdate = binstalled && !!blatest && (!binstalledVersion || blatest !== binstalledVersion);
if (this._sort === "updates_first") {
if (aupdate !== bupdate) return aupdate ? -1 : 1;
return an.localeCompare(bn);
}
if (this._sort === "installed_first") {
if (ainstalled !== binstalled) return ainstalled ? -1 : 1;
return an.localeCompare(bn);
}
return an.localeCompare(bn); return an.localeCompare(bn);
}); });
const categories = Array.from(
new Set(repos.map((r) => this._safeText(r?.category)).filter((c) => !!c))
).sort();
const cards = filtered const cards = filtered
.map((r) => { .map((r) => {
const id = this._safeId(r?.id); const id = this._safeId(r?.id);
const name = this._safeText(r?.name) || "Unnamed repository"; const name = this._safeText(r?.name) || "Unnamed repository";
const url = this._safeText(r?.url) || "";
const desc = this._safeText(r?.description) || ""; const desc = this._safeText(r?.description) || "";
const latest = this._safeText(r?.latest_version); const latest = this._safeText(r?.latest_version);
@@ -620,9 +746,7 @@ class BahmcloudStorePanel extends HTMLElement {
<div> <div>
<div><strong>${this._esc(name)}</strong></div> <div><strong>${this._esc(name)}</strong></div>
<div class="muted">${this._esc(desc)}</div> <div class="muted">${this._esc(desc)}</div>
<div class="muted small" style="margin-top:8px;"> <div class="muted small" style="margin-top:8px;">${this._esc(url)}</div>
Creator: ${this._esc(this._safeText(r?.owner || "-"))} · Latest: ${this._esc(latest || "-")} · Meta: ${this._esc(this._safeText(r?.meta_source || "-"))}
</div>
</div> </div>
${badgeHtml} ${badgeHtml}
</div> </div>
@@ -633,19 +757,26 @@ class BahmcloudStorePanel extends HTMLElement {
return ` return `
<div class="filters"> <div class="filters">
<input id="q" placeholder="Search repositories…" value="${this._esc(this._search)}" /> <input id="q" placeholder="Search…" value="${this._esc(this._search)}" />
<select id="cat"> <select id="cat">
<option value="all">All categories</option> <option value="all">All categories</option>
${categories.map((c) => `<option value="${this._esc(c)}" ${this._category === c ? "selected" : ""}>${this._esc(c)}</option>`).join("")} ${categories.map((c) => `<option value="${this._esc(c)}" ${this._category === c ? "selected" : ""}>${this._esc(c)}</option>`).join("")}
</select> </select>
<select id="prov"> <select id="filter">
<option value="all">All providers</option> <option value="all" ${this._filter === "all" ? "selected" : ""}>All</option>
<option value="custom" ${this._provider === "custom" ? "selected" : ""}>Custom</option> <option value="installed" ${this._filter === "installed" ? "selected" : ""}>Installed</option>
${providers.map((p) => `<option value="${p}" ${this._provider === p ? "selected" : ""}>${p.toUpperCase()}</option>`).join("")} <option value="not_installed" ${this._filter === "not_installed" ? "selected" : ""}>Not installed</option>
<option value="updates" ${this._filter === "updates" ? "selected" : ""}>Updates available</option>
<option value="custom" ${this._filter === "custom" ? "selected" : ""}>Custom repos</option>
</select>
<select id="sort">
<option value="az" ${this._sort === "az" ? "selected" : ""}>AZ</option>
<option value="updates_first" ${this._sort === "updates_first" ? "selected" : ""}>Updates first</option>
<option value="installed_first" ${this._sort === "installed_first" ? "selected" : ""}>Installed first</option>
</select> </select>
</div> </div>
<div class="muted small">BCS ${this._esc(this._data.version || "-")} · Repositories: ${repos.length}</div> <div class="muted small">Version: ${this._esc(this._data.version || "-")} · Repositories: ${repos.length}</div>
<div class="grid" style="margin-top:12px;"> <div class="grid" style="margin-top:12px;">
${cards || `<div class="card">No repositories found.</div>`} ${cards || `<div class="card">No repositories found.</div>`}
@@ -658,11 +789,33 @@ class BahmcloudStorePanel extends HTMLElement {
const q = root.getElementById("q"); const q = root.getElementById("q");
const cat = root.getElementById("cat"); const cat = root.getElementById("cat");
const prov = root.getElementById("prov"); const filter = root.getElementById("filter");
const sort = root.getElementById("sort");
if (q) q.addEventListener("input", (e) => { this._search = e?.target?.value || ""; this._update(); }); if (q) {
if (cat) cat.addEventListener("change", (e) => { this._category = e?.target?.value || "all"; this._update(); }); q.addEventListener("input", (e) => {
if (prov) prov.addEventListener("change", (e) => { this._provider = e?.target?.value || "all"; this._update(); }); this._search = e?.target?.value || "";
this._update();
});
}
if (cat) {
cat.addEventListener("change", (e) => {
this._category = e?.target?.value || "all";
this._update();
});
}
if (filter) {
filter.addEventListener("change", (e) => {
this._filter = e?.target?.value || "all";
this._update();
});
}
if (sort) {
sort.addEventListener("change", (e) => {
this._sort = e?.target?.value || "az";
this._update();
});
}
root.querySelectorAll("[data-open]").forEach((el) => { root.querySelectorAll("[data-open]").forEach((el) => {
const id = el.getAttribute("data-open"); const id = el.getAttribute("data-open");
@@ -673,9 +826,9 @@ class BahmcloudStorePanel extends HTMLElement {
_renderAbout() { _renderAbout() {
return ` return `
<div class="card"> <div class="card">
<div><strong>Installation & Updates</strong></div> <div><strong>About</strong></div>
<div class="muted" style="margin-top:10px;"> <div class="muted" style="margin-top:10px;">
Installation and updates are now available via the Store UI. Bahmcloud Store is a provider-neutral repository index and UI for Home Assistant.
</div> </div>
<div class="muted small" style="margin-top:10px;"> <div class="muted small" style="margin-top:10px;">
Current integration version: <strong>${this._esc(this._data?.version || "-")}</strong> Current integration version: <strong>${this._esc(this._data?.version || "-")}</strong>
@@ -692,18 +845,15 @@ class BahmcloudStorePanel extends HTMLElement {
const url = this._safeText(r?.url) || ""; const url = this._safeText(r?.url) || "";
const desc = this._safeText(r?.description) || ""; const desc = this._safeText(r?.description) || "";
const repoId = this._safeId(r?.id); const infoBits = [
this._safeText(r?.owner) ? `Creator: ${this._safeText(r?.owner)}` : "Creator: -",
const installed = this._asBoolStrict(r?.installed); this._safeText(r?.latest_version) ? `Latest: ${this._safeText(r?.latest_version)}` : "Latest: -",
const installedVersion = this._safeText(r?.installed_version); this._safeText(r?.provider) ? `Provider: ${this._safeText(r?.provider)}` : null,
const installedDomains = Array.isArray(r?.installed_domains) ? r.installed_domains : []; this._safeText(r?.category) ? `Category: ${this._safeText(r?.category)}` : null,
const latestVersion = this._safeText(r?.latest_version); this._safeText(r?.meta_author) ? `Author: ${this._safeText(r?.meta_author)}` : null,
this._safeText(r?.meta_maintainer) ? `Maintainer: ${this._safeText(r?.meta_maintainer)}` : null,
const busyInstall = this._installingRepoId === repoId; this._safeText(r?.meta_source) ? `Meta: ${this._safeText(r?.meta_source)}` : null,
const busyUpdate = this._updatingRepoId === repoId; ].filter(Boolean);
const busy = busyInstall || busyUpdate;
const updateAvailable = installed && !!latestVersion && (!installedVersion || latestVersion !== installedVersion);
const readmeBlock = this._readmeLoading const readmeBlock = this._readmeLoading
? `<div class="card">Loading README…</div>` ? `<div class="card">Loading README…</div>`
@@ -715,7 +865,14 @@ class BahmcloudStorePanel extends HTMLElement {
<div class="muted small">Rendered Markdown</div> <div class="muted small">Rendered Markdown</div>
</div> </div>
<div id="readmePretty" class="md" style="margin-top:12px;"></div> <div class="readmeWrap ${this._readmeExpanded ? "expanded" : "collapsed"}">
<div id="readmePretty" class="md"></div>
</div>
${this._readmeCanToggle ? `
<div class="readmeActions">
<button class="link" id="btnReadmeToggle">${this._readmeExpanded ? "Show less" : "Show more"}</button>
</div>
` : ``}
<details> <details>
<summary>Show raw Markdown</summary> <summary>Show raw Markdown</summary>
@@ -732,8 +889,23 @@ class BahmcloudStorePanel extends HTMLElement {
</div> </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 : [];
const latestVersion = this._safeText(r?.latest_version);
const busyInstall = this._installingRepoId === repoId;
const busyUpdate = this._updatingRepoId === repoId;
const busyUninstall = this._uninstallingRepoId === repoId;
const busy = busyInstall || busyUpdate || busyUninstall;
const updateAvailable = installed && !!latestVersion && (!installedVersion || latestVersion !== installedVersion);
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 restartHint = this._restartRequired const restartHint = this._restartRequired
? ` ? `
@@ -757,12 +929,7 @@ class BahmcloudStorePanel extends HTMLElement {
<div> <div>
<div><strong style="font-size:16px;">${this._esc(name)}</strong></div> <div><strong style="font-size:16px;">${this._esc(name)}</strong></div>
<div class="muted">${this._esc(desc)}</div> <div class="muted">${this._esc(desc)}</div>
<div class="muted small" style="margin-top:8px;"> <div class="muted small" style="margin-top:8px;">${this._esc(infoBits.join(" · "))}</div>
Creator: ${this._esc(this._safeText(r?.owner || "-"))}
· Latest: ${this._esc(latestVersion || "-")}
· Provider: ${this._esc(this._safeText(r?.provider || "-"))}
· Meta: ${this._esc(this._safeText(r?.meta_source || "-"))}
</div>
<div class="muted small" style="margin-top:8px;"> <div class="muted small" style="margin-top:8px;">
<a href="${this._esc(url)}" target="_blank" rel="noreferrer">Open repository</a> <a href="${this._esc(url)}" target="_blank" rel="noreferrer">Open repository</a>
</div> </div>
@@ -790,6 +957,7 @@ class BahmcloudStorePanel extends HTMLElement {
<div class="row" style="margin-top:14px; gap:10px; flex-wrap:wrap;"> <div class="row" style="margin-top:14px; gap:10px; flex-wrap:wrap;">
${installBtn} ${installBtn}
${updateBtn} ${updateBtn}
${uninstallBtn}
</div> </div>
${restartHint} ${restartHint}
@@ -802,13 +970,44 @@ class BahmcloudStorePanel extends HTMLElement {
_wireDetail() { _wireDetail() {
const root = this.shadowRoot; const root = this.shadowRoot;
// Always wire action buttons (even if README is already loaded)
const btnInstall = root.getElementById("btnInstall"); const btnInstall = root.getElementById("btnInstall");
const btnUpdate = root.getElementById("btnUpdate"); const btnUpdate = root.getElementById("btnUpdate");
const btnUninstall = root.getElementById("btnUninstall");
const btnRestart = root.getElementById("btnRestart"); const btnRestart = root.getElementById("btnRestart");
const btnReadmeToggle = root.getElementById("btnReadmeToggle");
if (btnInstall) btnInstall.addEventListener("click", () => { if (!btnInstall.disabled && this._detailRepoId) this._installRepo(this._detailRepoId); }); if (btnInstall) {
if (btnUpdate) btnUpdate.addEventListener("click", () => { if (!btnUpdate.disabled && this._detailRepoId) this._updateRepo(this._detailRepoId); }); btnInstall.addEventListener("click", () => {
if (btnRestart) btnRestart.addEventListener("click", () => this._restartHA()); if (btnInstall.disabled) return;
if (this._detailRepoId) this._installRepo(this._detailRepoId);
});
}
if (btnUpdate) {
btnUpdate.addEventListener("click", () => {
if (btnUpdate.disabled) return;
if (this._detailRepoId) this._updateRepo(this._detailRepoId);
});
}
if (btnUninstall) {
btnUninstall.addEventListener("click", () => {
if (btnUninstall.disabled) return;
if (this._detailRepoId) this._uninstallRepo(this._detailRepoId);
});
}
if (btnRestart) {
btnRestart.addEventListener("click", () => this._restartHA());
}
if (btnReadmeToggle) {
btnReadmeToggle.addEventListener("click", () => {
this._readmeExpanded = !this._readmeExpanded;
this._update();
});
}
const mount = root.getElementById("readmePretty"); const mount = root.getElementById("readmePretty");
if (!mount) return; if (!mount) return;
@@ -836,54 +1035,6 @@ class BahmcloudStorePanel extends HTMLElement {
} catch (_) {} } catch (_) {}
} }
_renderFabs() {
const r = this._detailRepo;
if (!r) return "";
const repoId = this._safeId(r?.id);
const installed = this._asBoolStrict(r?.installed);
const latest = this._safeText(r?.latest_version);
const installedVersion = this._safeText(r?.installed_version);
const busy = this._installingRepoId === repoId || this._updatingRepoId === repoId;
const updateAvailable = installed && !!latest && (!installedVersion || latest !== installedVersion);
const installDisabled = installed || busy;
const updateDisabled = !updateAvailable || busy;
return `
<div class="fabs">
<button class="fabbtn primary" id="fabOpen" title="Open repository">↗</button>
<button class="fabbtn" id="fabReload" title="Reload README">⟳</button>
<button class="fabbtn" id="fabInstall" title="${installDisabled ? (installed ? "Already installed" : "Installing…") : "Install"}" ${installDisabled ? "disabled" : ""}></button>
<button class="fabbtn" id="fabUpdate" title="${updateDisabled ? (!installed ? "Not installed" : "No update available") : "Update"}" ${updateDisabled ? "disabled" : ""}>↑</button>
<button class="fabbtn" id="fabInfo" title="About">i</button>
</div>
`;
}
_wireFabs() {
const root = this.shadowRoot;
const r = this._detailRepo;
if (!r) return;
const url = this._safeText(r?.url);
const repoId = this._safeId(r?.id);
const open = root.getElementById("fabOpen");
const reload = root.getElementById("fabReload");
const install = root.getElementById("fabInstall");
const update = root.getElementById("fabUpdate");
const info = root.getElementById("fabInfo");
if (open) open.addEventListener("click", () => url && window.open(url, "_blank", "noreferrer"));
if (reload) reload.addEventListener("click", () => this._detailRepoId && this._loadReadme(this._detailRepoId));
if (install) install.addEventListener("click", () => { if (!install.disabled) this._installRepo(repoId); });
if (update) update.addEventListener("click", () => { if (!update.disabled) this._updateRepo(repoId); });
if (info) info.addEventListener("click", () => { this._view = "about"; this._update(); });
}
_renderManage() { _renderManage() {
const repos = Array.isArray(this._data.repos) ? this._data.repos : []; const repos = Array.isArray(this._data.repos) ? this._data.repos : [];

View File

@@ -1,10 +1,24 @@
body { font-family: system-ui, sans-serif; margin:0; } body { font-family: system-ui, sans-serif; margin:0; }
.wrap { padding: 16px; max-width: 1000px; margin: 0 auto; } .wrap { padding: 16px; max-width: 1000px; margin: 0 auto; }
.card { border: 1px solid #ddd; border-radius: 10px; padding: 12px; margin: 10px 0; } .card { border: 1px solid #ddd; border-radius: 10px; padding: 12px; margin: 10px 0; }
.row { display:flex; justify-content:space-between; gap: 12px; align-items: flex-start; } .row { display:flex; justify-content:space-between; gap: 12px; align-items: flex-start; flex-wrap: wrap; min-width:0; }
.badge { border: 1px solid #bbb; border-radius: 999px; padding: 2px 8px; font-size: 12px; height: fit-content; } .badge { border: 1px solid #bbb; border-radius: 999px; padding: 2px 8px; font-size: 12px; height: fit-content; }
.muted { color: #666; font-size: 13px; margin-top: 4px; } .muted { color: #666; font-size: 13px; margin-top: 4px; }
.actions { display:flex; gap: 8px; margin-top: 10px; } .actions { display:flex; gap: 8px; margin-top: 10px; }
button { padding: 8px 12px; cursor:pointer; } button { padding: 8px 12px; cursor:pointer; }
button[disabled] { opacity: 0.6; cursor: not-allowed; } button[disabled] { opacity: 0.6; cursor: not-allowed; }
a { color: inherit; } a { color: inherit; }
/* Basic markdown safety (in case styles.css is used by older panels) */
.md { max-width: 100%; overflow-x: auto; }
.md table { display:block; max-width:100%; overflow-x:auto; }
.md img { max-width: 100%; height: auto; }
/* README UX (E2): collapsible preview (standalone page only) */
.readmeWrap{ border:1px solid #ddd; border-radius:10px; padding:12px; background: #f7f7f7; max-width:100%; }
.readmeWrap.collapsed{ max-height:260px; overflow:hidden; position:relative; }
.readmeWrap.collapsed::after{ content:""; position:absolute; left:0; right:0; bottom:0; height:56px; background: linear-gradient(to bottom, rgba(247,247,247,0), #f7f7f7); pointer-events:none; }
.readmeWrap.expanded{ max-height:70vh; overflow:auto; }
.readmeActions{ display:flex; justify-content:flex-end; margin-top:10px; }
button.link{ border:none; background:transparent; padding:6px 10px; color:#1E88E5; }
button.link:hover{ text-decoration:underline; }

View File

@@ -0,0 +1,55 @@
from __future__ import annotations
import logging
import voluptuous as vol
from homeassistant.components.repairs import RepairsFlow
from homeassistant.core import HomeAssistant
from homeassistant import data_entry_flow
from .core import RESTART_REQUIRED_ISSUE_ID
_LOGGER = logging.getLogger(__name__)
class BCSRestartRequiredFlow(RepairsFlow):
"""Repairs flow to restart Home Assistant after BCS install/update."""
def __init__(self, hass: HomeAssistant) -> None:
self.hass = hass
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
return await self.async_step_confirm(user_input)
async def async_step_confirm(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
if user_input is not None:
_LOGGER.info("BCS repairs flow: restarting Home Assistant (user confirmed)")
await self.hass.services.async_call(
"homeassistant",
"restart",
{},
blocking=False,
)
return self.async_create_entry(title="", data={})
return self.async_show_form(
step_id="confirm",
data_schema=vol.Schema({}),
)
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: dict[str, str | int | float | None] | None,
) -> RepairsFlow:
"""Create a repairs flow for BCS fixable issues."""
if issue_id == RESTART_REQUIRED_ISSUE_ID:
return BCSRestartRequiredFlow(hass)
raise data_entry_flow.UnknownHandler

View File

@@ -4,5 +4,15 @@
"title": "Restart required", "title": "Restart required",
"description": "One or more integrations were installed or updated by Bahmcloud Store. Restart Home Assistant to load the changes." "description": "One or more integrations were installed or updated by Bahmcloud Store. Restart Home Assistant to load the changes."
} }
},
"repair_flow": {
"restart_required": {
"step": {
"confirm": {
"title": "Restart Home Assistant",
"description": "Bahmcloud Store installed or updated integrations. Restart Home Assistant now to apply the changes."
}
}
}
} }
} }

View File

@@ -26,7 +26,6 @@ def _pretty_repo_name(core: BCSCore, repo_id: str) -> str:
except Exception: except Exception:
pass pass
# Fallbacks
if repo_id.startswith("index:"): if repo_id.startswith("index:"):
return f"BCS Index {repo_id.split(':', 1)[1]}" return f"BCS Index {repo_id.split(':', 1)[1]}"
if repo_id.startswith("custom:"): if repo_id.startswith("custom:"):
@@ -53,11 +52,11 @@ class BCSRepoUpdateEntity(UpdateEntity):
# Stable unique id (do NOT change) # Stable unique id (do NOT change)
self._attr_unique_id = f"{DOMAIN}:{repo_id}" self._attr_unique_id = f"{DOMAIN}:{repo_id}"
# Human-friendly name in UI self._refresh_display_name()
pretty = _pretty_repo_name(core, repo_id)
self._attr_name = pretty
# Title shown in the entity dialog def _refresh_display_name(self) -> None:
pretty = _pretty_repo_name(self._core, self._repo_id)
self._attr_name = pretty
self._attr_title = pretty self._attr_title = pretty
@property @property
@@ -102,9 +101,7 @@ class BCSRepoUpdateEntity(UpdateEntity):
async def async_install(self, version: str | None, backup: bool, **kwargs: Any) -> None: async def async_install(self, version: str | None, backup: bool, **kwargs: Any) -> None:
if version is not None: if version is not None:
_LOGGER.debug( _LOGGER.debug("BCS update entity requested specific version=%s (ignored)", version)
"BCS update entity requested specific version=%s (ignored)", version
)
self._in_progress = True self._in_progress = True
self.async_write_ha_state() self.async_write_ha_state()
@@ -117,19 +114,18 @@ class BCSRepoUpdateEntity(UpdateEntity):
@callback @callback
def _sync_entities( def _sync_entities(core: BCSCore, existing: dict[str, BCSRepoUpdateEntity], async_add_entities: AddEntitiesCallback) -> None:
core: BCSCore, """Ensure there is one update entity per installed repo AND keep names in sync."""
existing: dict[str, BCSRepoUpdateEntity],
async_add_entities: AddEntitiesCallback,
) -> None:
"""Ensure there is one update entity per installed repo."""
installed_map = getattr(core, "_installed_cache", {}) or {} installed_map = getattr(core, "_installed_cache", {}) or {}
new_entities: list[BCSRepoUpdateEntity] = [] new_entities: list[BCSRepoUpdateEntity] = []
for repo_id, data in installed_map.items(): for repo_id, data in installed_map.items():
if not isinstance(data, dict): if not isinstance(data, dict):
continue continue
if repo_id in existing: if repo_id in existing:
# IMPORTANT: Update display name after refresh, when repo.name becomes available.
existing[repo_id]._refresh_display_name()
continue continue
ent = BCSRepoUpdateEntity(core, repo_id) ent = BCSRepoUpdateEntity(core, repo_id)

View File

@@ -334,6 +334,27 @@ class BCSUpdateView(HomeAssistantView):
return web.json_response({"ok": False, "message": str(e) or "Update failed"}, status=500) return web.json_response({"ok": False, "message": str(e) or "Update failed"}, status=500)
class BCSUninstallView(HomeAssistantView):
url = "/api/bcs/uninstall"
name = "api:bcs_uninstall"
requires_auth = True
def __init__(self, core: Any) -> None:
self.core: BCSCore = core
async def post(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:
result = await self.core.uninstall_repo(repo_id)
return web.json_response(result, status=200)
except Exception as e:
_LOGGER.exception("BCS uninstall failed: %s", e)
return web.json_response({"ok": False, "message": str(e) or "Uninstall failed"}, status=500)
class BCSRestartView(HomeAssistantView): class BCSRestartView(HomeAssistantView):
url = "/api/bcs/restart" url = "/api/bcs/restart"
name = "api:bcs_restart" name = "api:bcs_restart"