Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c07f8615e4 | |||
| 9b209a15bf | |||
| 30258bd2c0 | |||
| 2c8ca490ea | |||
| 9e8a8e81b9 | |||
| f5b2534fdb | |||
| 8b3916c3fa | |||
| 13e71046f8 | |||
| 58e3674325 | |||
| 828d84caa3 | |||
| c18e93406a | |||
| 9af18ba090 | |||
| fff50a1580 | |||
| f8e9967c3a | |||
| 7bc493eb45 | |||
| b97b970a45 | |||
| 593e0c367d | |||
| 8e0817a64b | |||
| dfc7e44565 | |||
| c9c4f99fbf | |||
| 37cc11c9ee | |||
| 9c773c07e8 | |||
| c04612e159 | |||
| 5796012189 |
29
CHANGELOG.md
29
CHANGELOG.md
@@ -11,6 +11,35 @@ Sections:
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
- Native Home Assistant Update entities for installed repositories (shown under **Settings → System → Updates**).
|
||||
- Human-friendly update names based on repository name (instead of internal repo IDs like `index:1`).
|
||||
|
||||
### Changed
|
||||
- Update UI now behaves like official Home Assistant integrations (update action is triggered via the HA Updates screen).
|
||||
|
||||
## [0.5.2] - 2026-01-16
|
||||
|
||||
### Added
|
||||
|
||||
@@ -6,6 +6,7 @@ from datetime import timedelta
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.components.panel_custom import async_register_panel
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.helpers.discovery import async_load_platform
|
||||
|
||||
from .core import BCSCore, BCSConfig, BCSError
|
||||
|
||||
@@ -26,6 +27,10 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
||||
|
||||
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,
|
||||
|
||||
@@ -15,7 +15,8 @@ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit, urlparse
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.components import persistent_notification
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.util import yaml as ha_yaml
|
||||
|
||||
from .storage import BCSStorage, CustomRepo
|
||||
@@ -26,6 +27,9 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "bahmcloud_store"
|
||||
|
||||
SIGNAL_UPDATED = f"{DOMAIN}_updated"
|
||||
RESTART_REQUIRED_ISSUE_ID = "restart_required"
|
||||
|
||||
|
||||
class BCSError(Exception):
|
||||
"""BCS core error."""
|
||||
@@ -91,6 +95,9 @@ class BCSCore:
|
||||
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:
|
||||
@@ -107,12 +114,44 @@ class BCSCore:
|
||||
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)
|
||||
@@ -122,6 +161,11 @@ class BCSCore:
|
||||
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
|
||||
@@ -322,7 +366,6 @@ class BCSCore:
|
||||
if isinstance(d, list):
|
||||
installed_domains = [str(x) for x in d if str(x).strip()]
|
||||
|
||||
# IMPORTANT: this is the ref we installed (tag/release/branch)
|
||||
v = inst.get("installed_version")
|
||||
installed_version = str(v) if v is not None else None
|
||||
|
||||
@@ -367,7 +410,6 @@ class BCSCore:
|
||||
)
|
||||
|
||||
def _pick_ref_for_install(self, repo: RepoItem) -> str:
|
||||
# Prefer latest_version (release/tag/atom-derived), fallback to default branch, then main.
|
||||
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():
|
||||
@@ -375,13 +417,6 @@ class BCSCore:
|
||||
return "main"
|
||||
|
||||
def _build_zip_url(self, repo_url: str, ref: str) -> str:
|
||||
"""Build a public ZIP download URL (provider-neutral, no tokens).
|
||||
|
||||
Supports:
|
||||
- GitHub: codeload
|
||||
- GitLab: /-/archive/
|
||||
- Gitea (incl. Bahmcloud): /archive/<ref>.zip
|
||||
"""
|
||||
ref = (ref or "").strip()
|
||||
if not ref:
|
||||
raise BCSInstallError("Missing ref for ZIP download")
|
||||
@@ -485,8 +520,9 @@ class BCSCore:
|
||||
cache: dict[str, Any] = {}
|
||||
for it in items:
|
||||
cache[it.repo_id] = {
|
||||
"installed": True,
|
||||
"domains": it.domains,
|
||||
"installed_version": it.installed_version, # BCS ref
|
||||
"installed_version": it.installed_version,
|
||||
"installed_manifest_version": it.installed_manifest_version,
|
||||
"ref": it.ref,
|
||||
"installed_at": it.installed_at,
|
||||
@@ -534,11 +570,7 @@ class BCSCore:
|
||||
if not installed_domains:
|
||||
raise BCSInstallError("No integrations found under custom_components/ (missing manifest.json)")
|
||||
|
||||
# informational only (many repos are wrong here)
|
||||
installed_manifest_version = await self._read_installed_manifest_version(installed_domains[0])
|
||||
|
||||
# IMPORTANT: BCS "installed_version" is the ref we installed (tag/release/branch),
|
||||
# so update logic won't break when manifest.json is 0.0.0 or outdated.
|
||||
installed_version = ref
|
||||
|
||||
await self.storage.set_installed_repo(
|
||||
@@ -551,12 +583,7 @@ class BCSCore:
|
||||
)
|
||||
await self._refresh_installed_cache()
|
||||
|
||||
persistent_notification.async_create(
|
||||
self.hass,
|
||||
"Bahmcloud Store installation finished. A Home Assistant restart is required to load the integration.",
|
||||
title="Bahmcloud Store",
|
||||
notification_id="bcs_restart_required",
|
||||
)
|
||||
self._mark_restart_required()
|
||||
|
||||
_LOGGER.info(
|
||||
"BCS install complete: repo_id=%s domains=%s installed_ref=%s manifest_version=%s",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"domain": "bahmcloud_store",
|
||||
"name": "Bahmcloud Store",
|
||||
"version": "0.5.2",
|
||||
"version": "0.5.5",
|
||||
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
|
||||
"platforms": ["update"],
|
||||
"requirements": [],
|
||||
"codeowners": ["@bahmcloud"],
|
||||
"iot_class": "local_polling"
|
||||
|
||||
55
custom_components/bahmcloud_store/repairs.py
Normal file
55
custom_components/bahmcloud_store/repairs.py
Normal 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
|
||||
18
custom_components/bahmcloud_store/strings.json
Normal file
18
custom_components/bahmcloud_store/strings.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"issues": {
|
||||
"restart_required": {
|
||||
"title": "Restart required",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# NOTE:
|
||||
# Update entities will be implemented once installation/provider resolution is in place.
|
||||
# This stub prevents platform load errors and keeps the integration stable in 0.3.0.
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
|
||||
from .core import DOMAIN, SIGNAL_UPDATED, BCSCore
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _pretty_repo_name(core: BCSCore, repo_id: str) -> str:
|
||||
"""Return a human-friendly name for a repo update entity."""
|
||||
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
|
||||
|
||||
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}"
|
||||
|
||||
self._refresh_display_name()
|
||||
|
||||
def _refresh_display_name(self) -> None:
|
||||
pretty = _pretty_repo_name(self._core, self._repo_id)
|
||||
self._attr_name = pretty
|
||||
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()
|
||||
|
||||
try:
|
||||
await self._core.update_repo(self._repo_id)
|
||||
finally:
|
||||
self._in_progress = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
@callback
|
||||
def _sync_entities(core: BCSCore, existing: dict[str, BCSRepoUpdateEntity], async_add_entities: AddEntitiesCallback) -> None:
|
||||
"""Ensure there is one update entity per installed repo AND keep names in sync."""
|
||||
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:
|
||||
# IMPORTANT: Update display name after refresh, when repo.name becomes available.
|
||||
existing[repo_id]._refresh_display_name()
|
||||
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(
|
||||
@@ -14,4 +145,18 @@ async def async_setup_platform(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
discovery_info=None,
|
||||
):
|
||||
return
|
||||
"""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)
|
||||
Reference in New Issue
Block a user