136 Commits
0.4.1 ... 0.6.7

Author SHA1 Message Date
33502a6d99 add 0.6.7 2026-01-19 07:16:35 +00:00
1306ee9518 Change 0.6.7 2026-01-19 07:15:48 +00:00
e37546cab1 0.6 7 2026-01-19 07:15:30 +00:00
88c3233fd1 0.6.7 2026-01-19 07:14:58 +00:00
02f3047080 0.6 7 2026-01-19 07:14:22 +00:00
d4012589e6 add 0.6.6 2026-01-18 19:55:59 +00:00
8ac67fa60c 0.6.6 2026-01-18 19:53:34 +00:00
981490c152 0.6.6 2026-01-18 19:53:12 +00:00
99b2a0f0c5 0.6.6 2026-01-18 19:52:27 +00:00
7ead494765 0.6.6 2026-01-18 19:51:31 +00:00
342b6f6c57 0.6.6 2026-01-18 19:50:44 +00:00
66ca63b2be I 2026-01-18 19:35:19 +00:00
e8325f722f I 2026-01-18 19:32:58 +00:00
7c1a91937a add 0.6.5 2026-01-18 18:55:40 +00:00
7ac3289bb7 0.6.5 2026-01-18 18:53:52 +00:00
19bdbd1b9a 0.6.5 2026-01-18 18:53:30 +00:00
24363cd2ac 0.6.5 2026-01-18 18:52:55 +00:00
e19ca5bff1 0.6.5 2026-01-18 18:51:48 +00:00
05897d4370 0.6.5 2026-01-18 18:51:17 +00:00
7a3a28d87f add 0.6.4 2026-01-18 16:58:08 +00:00
240cded8a9 0.6.4 2026-01-18 16:56:23 +00:00
31e241f052 0.6.4 2026-01-18 16:56:04 +00:00
de579682a0 0.6.4 2026-01-18 16:55:36 +00:00
9acbd5046c Add 0.6.3 2026-01-18 15:54:21 +00:00
8d63c88e69 0.6.3 2026-01-18 15:53:52 +00:00
cffb0af60e 0.6.3 2026-01-18 15:53:03 +00:00
857b7a127a 0.6 3 2026-01-18 15:52:28 +00:00
66b24ece48 0.6.3 2026-01-18 15:52:01 +00:00
0cc3b466e0 0.6.3 2026-01-18 15:51:34 +00:00
f1e03b31a1 add 0.6.2 2026-01-18 13:16:42 +00:00
4e12d596d6 0.6.2 2026-01-18 13:12:47 +00:00
fa97f89afb 0.6.2 2026-01-18 13:12:21 +00:00
0718bee185 0.6.2 2026-01-18 13:11:39 +00:00
1a53107450 0.6.2 2026-01-18 13:10:59 +00:00
ab82cc6fd3 0.6.2 2026-01-18 13:10:16 +00:00
8e51f144e1 0.6.2 2026-01-18 13:09:37 +00:00
f292e22301 Fix restore version 2026-01-18 09:08:25 +00:00
2eb194c001 Add 0.6 1 2026-01-18 09:07:40 +00:00
f4e367987a 0.6.1 2026-01-18 09:07:03 +00:00
08aa4b5e15 0.6.0 2026-01-18 08:37:07 +00:00
b1676482f0 0.6.0 2026-01-18 08:36:34 +00:00
e46cd6e488 0.6.0 2026-01-18 08:34:44 +00:00
edd2fdd3fb 0.6.0 2026-01-18 08:33:34 +00:00
a4a0c1462b 0.6.0 2026-01-18 08:32:51 +00:00
196e63c08e 0.6.0 2026-01-18 08:32:06 +00:00
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
c18e93406a 0.5.3 2026-01-16 19:20:07 +00:00
9af18ba090 0.5.3 2026-01-16 19:19:23 +00:00
fff50a1580 0.5.3 2026-01-16 19:18:47 +00:00
f8e9967c3a 0.5.3 2026-01-16 19:18:09 +00:00
7bc493eb45 0.5.3 2026-01-16 19:16:39 +00:00
b97b970a45 Dump 2026-01-16 19:16:01 +00:00
593e0c367d 0.5.3 2026-01-16 19:14:35 +00:00
8e0817a64b 0.5.3 2026-01-16 19:13:56 +00:00
dfc7e44565 0.5.3 2026-01-16 19:13:17 +00:00
c9c4f99fbf 0.5.3 2026-01-16 19:12:43 +00:00
37cc11c9ee 0.5.3 2026-01-16 19:12:10 +00:00
9c773c07e8 0.5.3 2026-01-16 19:11:26 +00:00
c04612e159 0.5.3 2026-01-16 19:10:35 +00:00
5796012189 0.5.3 2026-01-16 19:09:47 +00:00
01576153d8 Add 0.5.2 2026-01-16 17:31:49 +00:00
30484a08c1 V0. 5.2 2026-01-16 17:30:51 +00:00
faf122aa1c Fic install 2026-01-16 17:27:38 +00:00
1e86df49e9 Fux insta 2026-01-16 17:27:02 +00:00
df631eec9e Fix install 2026-01-16 17:26:22 +00:00
07240d1268 Add 2026-01-16 16:50:25 +00:00
50587ffbbd 12 2026-01-16 16:17:43 +00:00
d6347e7e59 . 2026-01-16 16:06:52 +00:00
870e77ec13 .. 2026-01-15 20:40:04 +00:00
38fb9fb073 .. 2026-01-15 20:32:21 +00:00
c20bd4dd07 .. 2026-01-15 20:25:34 +00:00
296c816633 . 2026-01-15 20:12:30 +00:00
18a2b5529c . 2026-01-15 19:53:44 +00:00
246fab7e1e . 2026-01-15 19:53:07 +00:00
ce5802721f . 2026-01-15 19:52:05 +00:00
2f46966fe2 . 2026-01-15 19:51:26 +00:00
132f9e27c1 revert 6488b434d8
revert custom_components/bahmcloud_store/core.py aktualisiert
2026-01-15 18:02:30 +00:00
618511be73 revert bffc594da5
revert custom_components/bahmcloud_store/views.py aktualisiert
2026-01-15 18:02:11 +00:00
6488b434d8 custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 18:00:56 +00:00
bffc594da5 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 18:00:32 +00:00
d78217100c custom_components/bahmcloud_store/manifest.json aktualisiert 2026-01-15 17:53:28 +00:00
09e1ef1af5 CHANGELOG.md aktualisiert 2026-01-15 17:53:13 +00:00
9ad558c9ab custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 17:42:38 +00:00
19df0eea22 custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 17:41:58 +00:00
745979b9a6 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 17:19:45 +00:00
f861b2490a custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 17:18:45 +00:00
32946c1a98 custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 17:17:48 +00:00
a9a681d801 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 17:06:43 +00:00
2ae6ac43a5 custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 17:01:41 +00:00
504c126c2c custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 16:59:33 +00:00
85cc97b557 custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 16:58:31 +00:00
4ca80a9c88 custom_components/bahmcloud_store/panel/app.js aktualisiert 2026-01-15 16:45:23 +00:00
ac5bc8a6f4 custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 16:21:14 +00:00
c4361cc8bd custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 16:13:42 +00:00
1794d579d2 custom_components/bahmcloud_store/panel/app.js aktualisiert 2026-01-15 16:08:48 +00:00
bcfbf7151c custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 16:04:59 +00:00
14 changed files with 4778 additions and 715 deletions

View File

@@ -11,6 +11,208 @@ Sections:
---
## [0.6.7] - 2026-01-19
### Fixed
- Repository metadata loaded on demand is now persisted and restored after Home Assistant restart.
- Background enrichment reliably updates latest versions and descriptions for HACS repositories.
- Cached repository data is correctly restored on startup for non-installed repositories.
### Changed
- Repository details are only considered fully enriched once latest version information is available.
- Improved reliability of background cache synchronization without impacting startup performance.
## [0.6.6] - 2026-01-18
### Added
- Source filter to limit repositories by origin: BCS Official, HACS, or Custom.
- Visual source badges for repositories (BCS Official, HACS, Custom).
- Restored HACS enable/disable toggle in the Store UI.
### Changed
- HACS repositories now display human-readable names and descriptions based on official HACS metadata.
- Improved Store usability on mobile devices by fixing back navigation from repository detail view.
### Fixed
- Fixed missing HACS toggle after UI updates.
- Fixed mobile browser back button exiting the Store instead of returning to the repository list.
## [0.6.5] - 2026-01-18
### Added
- Separate handling of HACS official repositories with an enable/disable toggle in the Store UI.
- HACS repositories are now loaded independently from the main store index.
### Changed
- Store index can remain minimal and curated; HACS repositories are no longer required in store.yaml.
- Improved Store performance and clarity by clearly separating repository sources.
### Fixed
- Browser cache issues resolved by proper panel cache-busting for UI updates.
### Internal
- No changes to install, update, backup, or restore logic.
- Fully backward compatible with existing installations and configurations.
## [0.6.4] - 2026-01-18
### Fixed
- Fixed long Home Assistant startup times caused by background repository enrichment running too early.
### Changed
- Background repository enrichment is now started only after Home Assistant has fully started.
- Repository cache updates now run fully asynchronous without blocking Home Assistant startup.
### Internal
- Improved alignment with Home Assistant startup lifecycle.
- No functional changes to store behavior or UI.
## [0.6.3] - 2026-01-18
### Changed
- Improved Store performance for large indexes by avoiding full metadata enrichment during list refresh.
- Repository details are loaded on demand, reducing initial load time and network requests.
- Index refresh is skipped when the index content has not changed.
## [0.6.2] - 2026-01-18
### Added
- Selectable install/update version per repository (install older releases/tags to downgrade when needed).
- New API endpoint to list available versions for a repository: `GET /api/bcs/versions?repo_id=...`.
## [0.6.1] - 2026-01-18
### Fixed
- Restored integrations now correctly report the restored version instead of the latest installed version.
- Update availability is correctly recalculated after restoring a backup, allowing updates to be applied again.
- Improved restore compatibility with backups created before version metadata was introduced.
## [0.6.0] - 2026-01-18
### Added
- Restore feature with selection of the last available backups (up to 5 per domain).
- New API endpoints to list and restore backups:
- `GET /api/bcs/backups?repo_id=...`
- `POST /api/bcs/restore?repo_id=...&backup_id=...`
### Safety
- Restoring a backup triggers a “restart required” prompt to apply the recovered integration state.
### Notes
- This is a major release milestone consolidating install/update/uninstall, backup/rollback, and restore workflows.
## [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
### 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
- Install and update backend endpoints (`POST /api/bcs/install`, `POST /api/bcs/update`) to install repositories into `/config/custom_components`.
- Installed version tracking based on the actually installed ref (tag/release/branch), stored persistently to support repositories with outdated/`0.0.0` manifest versions.
- API fields `installed_version` (installed ref) and `installed_manifest_version` (informational) to improve transparency in the UI.
### Changed
- Update availability is now evaluated using the stored installed ref (instead of `manifest.json` version), preventing false-positive updates when repositories do not maintain manifest versions.
### Fixed
- Repositories with `manifest.json` version `0.0.0` (or stale versions) no longer appear as constantly requiring updates after installing the latest release/tag.
## [0.5.0] - 2026-01-15
### Added
- Manual refresh button that triggers a full backend refresh (store index + provider data).
- Unified refresh pipeline: startup, timer and UI now use the same refresh logic.
- Cache-busting for store index requests to always fetch the latest store.yaml.
### Improved
- Logging for store index loading and parsing.
- Refresh behavior now deterministic and verifiable via logs.
### Fixed
- Refresh button previously only reloaded cached data.
- Store index was not always reloaded immediately on user action.
## [0.4.1] - 2026-01-15
### Fixed
- Fixed GitLab README loading by using robust raw file endpoints.

19
bcs.yaml Normal file
View File

@@ -0,0 +1,19 @@
name: Bahmcloud Store
description: >
Provider-neutral custom integration store for Home Assistant.
Supports GitHub, GitLab, Gitea and Bahmcloud repositories with
a central index, UI panel and API, similar to HACS but independent.
category: Integrations
author: Bahmcloud
maintainer: Bahmcloud
domains:
- bahmcloud_store
min_ha_version: "2024.1.0"
homepage: https://git.bahmcloud.de/bahmcloud/bahmcloud_store
issues: https://git.bahmcloud.de/bahmcloud/bahmcloud_store/issues
source: https://git.bahmcloud.de/bahmcloud/bahmcloud_store

View File

@@ -4,10 +4,10 @@ import logging
from datetime import timedelta
from homeassistant.core import HomeAssistant
from homeassistant.const import Platform
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.components.panel_custom import async_register_panel
from homeassistant.helpers.event import async_track_time_interval, async_call_later
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.helpers.discovery import async_load_platform
from .core import BCSCore, BCSConfig, BCSError
@@ -20,40 +20,81 @@ CONF_STORE_URL = "store_url"
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
cfg = config.get(DOMAIN, {})
cfg = config.get(DOMAIN, {}) or {}
store_url = cfg.get(CONF_STORE_URL, DEFAULT_STORE_URL)
core = BCSCore(hass, BCSConfig(store_url=store_url))
hass.data[DOMAIN] = core
await core.register_http_views()
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,
BCSSettingsView,
BCSReadmeView,
BCSVersionsView,
BCSRepoDetailView,
BCSCustomRepoView,
BCSInstallView,
BCSUpdateView,
BCSUninstallView,
BCSBackupsView,
BCSRestoreView,
BCSRestartView,
)
hass.http.register_view(StaticAssetsView())
hass.http.register_view(BCSApiView(core))
hass.http.register_view(BCSSettingsView(core))
hass.http.register_view(BCSReadmeView(core))
hass.http.register_view(BCSVersionsView(core))
hass.http.register_view(BCSRepoDetailView(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(BCSBackupsView(core))
hass.http.register_view(BCSRestoreView(core))
hass.http.register_view(BCSRestartView(core))
# RESTORE: keep the module_url pattern that worked for you
await async_register_panel(
hass,
frontend_url_path="bahmcloud-store",
webcomponent_name="bahmcloud-store-panel",
module_url="/api/bahmcloud_store_static/panel.js?v=42",
# IMPORTANT: bump v to avoid caching old JS
module_url="/api/bahmcloud_store_static/panel.js?v=108",
sidebar_title="Bahmcloud Store",
sidebar_icon="mdi:store",
require_admin=True,
config={},
)
try:
await core.refresh()
except BCSError as e:
_LOGGER.error("Initial refresh failed: %s", e)
async def _do_startup_refresh(_now=None) -> None:
try:
await core.full_refresh(source="startup")
except BCSError as e:
_LOGGER.error("Initial refresh failed: %s", e)
# Do not block Home Assistant startup. Schedule the initial refresh after HA started.
def _on_ha_started(_event) -> None:
async_call_later(hass, 30, _do_startup_refresh)
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _on_ha_started)
async def periodic(_now) -> None:
try:
await core.refresh()
core.signal_updated()
await core.full_refresh(source="timer")
except BCSError as e:
_LOGGER.warning("Periodic refresh failed: %s", e)
except Exception as e: # pylint: disable=broad-exception-caught
_LOGGER.exception("Unexpected error during periodic refresh: %s", e)
interval = timedelta(seconds=int(core.refresh_seconds or 300))
async_track_time_interval(hass, periodic, interval)
interval_seconds = int(getattr(core, "refresh_seconds", 300) or 300)
async_track_time_interval(hass, periodic, timedelta(seconds=interval_seconds))
await async_load_platform(hass, Platform.UPDATE, DOMAIN, {}, config)
return True
return True

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,9 @@
{
"domain": "bahmcloud_store",
"name": "Bahmcloud Store",
"version": "0.4.1",
"version": "0.6.7",
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
"platforms": ["update"],
"requirements": [],
"codeowners": ["@bahmcloud"],
"iot_class": "local_polling"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,24 @@
body { font-family: system-ui, sans-serif; margin:0; }
.wrap { padding: 16px; max-width: 1000px; margin: 0 auto; }
.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; }
.muted { color: #666; font-size: 13px; margin-top: 4px; }
.actions { display:flex; gap: 8px; margin-top: 10px; }
button { padding: 8px 12px; cursor:pointer; }
button[disabled] { opacity: 0.6; cursor: not-allowed; }
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

@@ -6,6 +6,8 @@ import xml.etree.ElementTree as ET
from dataclasses import dataclass
from urllib.parse import quote_plus, urlparse
from packaging.version import InvalidVersion, Version
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -97,6 +99,36 @@ def _extract_meta(html: str, *, prop: str | None = None, name: str | None = None
return None
def _semver_key(tag: str) -> Version | None:
t = (tag or "").strip()
if not t:
return None
if t.startswith(("v", "V")):
t = t[1:]
try:
return Version(t)
except InvalidVersion:
return None
def _pick_highest_semver(tags: list[str]) -> str | None:
parsed: list[tuple[Version, str]] = []
for t in tags:
if not isinstance(t, str):
continue
ts = t.strip()
if not ts:
continue
v = _semver_key(ts)
if v is not None:
parsed.append((v, ts))
if not parsed:
return None
parsed.sort(key=lambda x: x[0], reverse=True)
return parsed[0][1]
async def _github_description_html(hass: HomeAssistant, owner: str, repo: str) -> str | None:
session = async_get_clientsession(hass)
url = f"https://github.com/{owner}/{repo}"
@@ -165,12 +197,22 @@ async def _github_latest_version_api(hass: HomeAssistant, owner: str, repo: str)
if isinstance(data, dict) and data.get("tag_name"):
return str(data["tag_name"]), "release"
# No releases -> pick highest semver from many tags (instead of per_page=1)
if status == 404:
data, _ = await _safe_json(session, f"https://api.github.com/repos/{owner}/{repo}/tags?per_page=1", headers=headers)
if isinstance(data, list) and data:
t = data[0]
if isinstance(t, dict) and t.get("name"):
return str(t["name"]), "tag"
data, _ = await _safe_json(session, f"https://api.github.com/repos/{owner}/{repo}/tags?per_page=100", headers=headers)
tags: list[str] = []
if isinstance(data, list):
for t in data:
if isinstance(t, dict) and t.get("name"):
tags.append(str(t["name"]))
best = _pick_highest_semver(tags)
if best:
return best, "tag"
# fallback: keep old behavior (first tag)
if tags:
return tags[0], "tag"
return None, None
@@ -190,17 +232,33 @@ async def _github_latest_version(hass: HomeAssistant, owner: str, repo: str) ->
async def _gitea_latest_version(hass: HomeAssistant, base: str, owner: str, repo: str) -> tuple[str | None, str | None]:
session = async_get_clientsession(hass)
data, _ = await _safe_json(session, f"{base}/api/v1/repos/{owner}/{repo}/releases?limit=1")
if isinstance(data, list) and data:
r = data[0]
if isinstance(r, dict) and r.get("tag_name"):
return str(r["tag_name"]), "release"
# releases: fetch multiple, pick highest semver (instead of limit=1)
data, _ = await _safe_json(session, f"{base}/api/v1/repos/{owner}/{repo}/releases?limit=50")
rel_tags: list[str] = []
if isinstance(data, list):
for r in data:
if isinstance(r, dict) and r.get("tag_name"):
rel_tags.append(str(r["tag_name"]))
data, _ = await _safe_json(session, f"{base}/api/v1/repos/{owner}/{repo}/tags?limit=1")
if isinstance(data, list) and data:
t = data[0]
if isinstance(t, dict) and t.get("name"):
return str(t["name"]), "tag"
best_rel = _pick_highest_semver(rel_tags)
if best_rel:
return best_rel, "release"
if rel_tags:
return rel_tags[0], "release"
# tags: fetch multiple, pick highest semver (instead of limit=1)
data, _ = await _safe_json(session, f"{base}/api/v1/repos/{owner}/{repo}/tags?limit=50")
tags: list[str] = []
if isinstance(data, list):
for t in data:
if isinstance(t, dict) and t.get("name"):
tags.append(str(t["name"]))
best = _pick_highest_semver(tags)
if best:
return best, "tag"
if tags:
return tags[0], "tag"
return None, None
@@ -213,18 +271,35 @@ async def _gitlab_latest_version(
project = quote_plus(f"{owner}/{repo}")
data, _ = await _safe_json(session, f"{base}/api/v4/projects/{project}/releases?per_page=1", headers=headers)
if isinstance(data, list) and data:
r = data[0]
if isinstance(r, dict) and r.get("tag_name"):
return str(r["tag_name"]), "release"
# releases: fetch multiple, pick highest semver (instead of per_page=1)
data, _ = await _safe_json(session, f"{base}/api/v4/projects/{project}/releases?per_page=50", headers=headers)
rel_tags: list[str] = []
if isinstance(data, list):
for r in data:
if isinstance(r, dict) and r.get("tag_name"):
rel_tags.append(str(r["tag_name"]))
data, _ = await _safe_json(session, f"{base}/api/v4/projects/{project}/repository/tags?per_page=1", headers=headers)
if isinstance(data, list) and data:
t = data[0]
if isinstance(t, dict) and t.get("name"):
return str(t["name"]), "tag"
best_rel = _pick_highest_semver(rel_tags)
if best_rel:
return best_rel, "release"
if rel_tags:
return rel_tags[0], "release"
# tags: fetch multiple, pick highest semver (instead of per_page=1)
data, _ = await _safe_json(session, f"{base}/api/v4/projects/{project}/repository/tags?per_page=50", headers=headers)
tags: list[str] = []
if isinstance(data, list):
for t in data:
if isinstance(t, dict) and t.get("name"):
tags.append(str(t["name"]))
best = _pick_highest_semver(tags)
if best:
return best, "tag"
if tags:
return tags[0], "tag"
# atom fallback
atom, status = await _safe_text(session, f"{base}/{owner}/{repo}/-/tags?format=atom", headers=headers)
if status == 200 and atom:
try:
@@ -430,3 +505,159 @@ async def fetch_readme_markdown(
continue
return None
async def fetch_repo_versions(
hass: HomeAssistant,
repo_url: str,
*,
provider: str | None = None,
default_branch: str | None = None,
limit: int = 20,
) -> list[dict[str, str]]:
"""List available versions/refs for a repository.
Returns a list of dicts with keys:
- ref: the ref to install (tag/release/branch)
- label: human-friendly label
- source: release|tag|branch
Notes:
- Uses public endpoints (no tokens) for public repositories.
- We prefer releases first (if available), then tags.
"""
repo_url = (repo_url or "").strip()
if not repo_url:
return []
prov = (provider or "").strip().lower() if provider else ""
if not prov:
prov = detect_provider(repo_url)
owner, repo = _split_owner_repo(repo_url)
if not owner or not repo:
return []
session = async_get_clientsession(hass)
headers = {"User-Agent": UA}
out: list[dict[str, str]] = []
seen: set[str] = set()
def _add(ref: str | None, label: str, source: str) -> None:
r = (ref or "").strip()
if not r or r in seen:
return
seen.add(r)
out.append({"ref": r, "label": label, "source": source})
# Always offer default branch as an explicit option.
if default_branch and str(default_branch).strip():
b = str(default_branch).strip()
_add(b, f"Branch: {b}", "branch")
try:
if prov == "github":
# Releases
gh_headers = {"Accept": "application/vnd.github+json", "User-Agent": UA}
data, _ = await _safe_json(
session,
f"https://api.github.com/repos/{owner}/{repo}/releases?per_page={int(limit)}",
headers=gh_headers,
)
if isinstance(data, list):
for r in data:
if not isinstance(r, dict):
continue
tag = r.get("tag_name")
name = r.get("name")
if tag:
lbl = str(tag)
if isinstance(name, str) and name.strip() and name.strip() != str(tag):
lbl = f"{tag}{name.strip()}"
_add(str(tag), lbl, "release")
# Tags
data, _ = await _safe_json(
session,
f"https://api.github.com/repos/{owner}/{repo}/tags?per_page={int(limit)}",
headers=gh_headers,
)
if isinstance(data, list):
for t in data:
if isinstance(t, dict) and t.get("name"):
_add(str(t["name"]), str(t["name"]), "tag")
return out
if prov == "gitlab":
u = urlparse(repo_url.rstrip("/"))
base = f"{u.scheme}://{u.netloc}"
project = quote_plus(f"{owner}/{repo}")
data, _ = await _safe_json(
session,
f"{base}/api/v4/projects/{project}/releases?per_page={int(limit)}",
headers=headers,
)
if isinstance(data, list):
for r in data:
if not isinstance(r, dict):
continue
tag = r.get("tag_name")
name = r.get("name")
if tag:
lbl = str(tag)
if isinstance(name, str) and name.strip() and name.strip() != str(tag):
lbl = f"{tag}{name.strip()}"
_add(str(tag), lbl, "release")
data, _ = await _safe_json(
session,
f"{base}/api/v4/projects/{project}/repository/tags?per_page={int(limit)}",
headers=headers,
)
if isinstance(data, list):
for t in data:
if isinstance(t, dict) and t.get("name"):
_add(str(t["name"]), str(t["name"]), "tag")
return out
# gitea (incl. Bahmcloud)
u = urlparse(repo_url.rstrip("/"))
base = f"{u.scheme}://{u.netloc}"
data, _ = await _safe_json(
session,
f"{base}/api/v1/repos/{owner}/{repo}/releases?limit={int(limit)}",
headers=headers,
)
if isinstance(data, list):
for r in data:
if not isinstance(r, dict):
continue
tag = r.get("tag_name")
name = r.get("name")
if tag:
lbl = str(tag)
if isinstance(name, str) and name.strip() and name.strip() != str(tag):
lbl = f"{tag}{name.strip()}"
_add(str(tag), lbl, "release")
data, _ = await _safe_json(
session,
f"{base}/api/v1/repos/{owner}/{repo}/tags?limit={int(limit)}",
headers=headers,
)
if isinstance(data, list):
for t in data:
if isinstance(t, dict) and t.get("name"):
_add(str(t["name"]), str(t["name"]), "tag")
return out
except Exception:
_LOGGER.debug("fetch_repo_versions failed for %s", repo_url, exc_info=True)
return out

View File

@@ -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

@@ -1,5 +1,6 @@
from __future__ import annotations
import time
import uuid
from dataclasses import dataclass
from typing import Any
@@ -18,21 +19,129 @@ class CustomRepo:
name: str | None = None
@dataclass
class InstalledRepo:
repo_id: str
url: str
domains: list[str]
installed_at: int
installed_version: str | None = None # BCS "installed ref" (tag/release/branch)
installed_manifest_version: str | None = None # informational only
ref: str | None = None # kept for backward compatibility / diagnostics
class BCSStorage:
"""Persistent storage for manually added repositories."""
"""Persistent storage for Bahmcloud Store.
Keys:
- custom_repos: list of manually added repositories
- installed_repos: mapping repo_id -> installed metadata
- settings: persistent user settings (e.g. toggles in the UI)
- hacs_cache: cached HACS metadata to improve UX (display names/descriptions)
- repo_cache: cached per-repo enrichment (names/descriptions/versions) to keep the UI populated after restart
"""
def __init__(self, hass: HomeAssistant) -> None:
self.hass = hass
self._store = Store(hass, _STORAGE_VERSION, _STORAGE_KEY)
self._store: Store[dict[str, Any]] = Store(hass, _STORAGE_VERSION, _STORAGE_KEY)
async def _load(self) -> dict[str, Any]:
data = await self._store.async_load()
if not data:
return {"custom_repos": []}
if "custom_repos" not in data:
data = await self._store.async_load() or {}
if not isinstance(data, dict):
data = {}
if "custom_repos" not in data or not isinstance(data.get("custom_repos"), list):
data["custom_repos"] = []
if "installed_repos" not in data or not isinstance(data.get("installed_repos"), dict):
data["installed_repos"] = {}
if "settings" not in data or not isinstance(data.get("settings"), dict):
data["settings"] = {}
if "hacs_cache" not in data or not isinstance(data.get("hacs_cache"), dict):
data["hacs_cache"] = {}
if "repo_cache" not in data or not isinstance(data.get("repo_cache"), dict):
data["repo_cache"] = {}
return data
async def get_repo_cache(self) -> dict[str, Any]:
"""Return cached per-repo enrichment data.
Shape:
{
"fetched_at": <unix_ts>,
"repos": {
"<repo_id>": {
"ts": <unix_ts>,
"url": "...",
"name": "...",
"provider_description": "...",
"meta_name": "...",
"meta_description": "...",
"meta_category": "...",
"meta_source": "...",
"latest_version": "...",
"latest_version_source": "...",
"default_branch": "...",
"owner": "...",
"provider_repo_name": "..."
}
}
}
"""
data = await self._load()
cache = data.get("repo_cache", {})
return cache if isinstance(cache, dict) else {}
async def set_repo_cache(self, cache: dict[str, Any]) -> None:
"""Persist cached per-repo enrichment data."""
data = await self._load()
data["repo_cache"] = cache if isinstance(cache, dict) else {}
await self._save(data)
async def get_hacs_cache(self) -> dict[str, Any]:
"""Return cached HACS metadata.
Shape:
{
"fetched_at": <unix_ts>,
"repos": {"owner/repo": {"name": "...", "description": "...", "domain": "..."}}
}
"""
data = await self._load()
cache = data.get("hacs_cache", {})
return cache if isinstance(cache, dict) else {}
async def set_hacs_cache(self, cache: dict[str, Any]) -> None:
"""Persist cached HACS metadata."""
data = await self._load()
data["hacs_cache"] = cache if isinstance(cache, dict) else {}
await self._save(data)
async def get_settings(self) -> dict[str, Any]:
"""Return persistent settings.
Currently used for UI/behavior toggles.
"""
data = await self._load()
settings = data.get("settings", {})
return settings if isinstance(settings, dict) else {}
async def set_settings(self, updates: dict[str, Any]) -> dict[str, Any]:
"""Update persistent settings and return the merged settings."""
data = await self._load()
settings = data.get("settings", {})
if not isinstance(settings, dict):
settings = {}
for k, v in (updates or {}).items():
settings[str(k)] = v
data["settings"] = settings
await self._save(data)
return settings
async def _save(self, data: dict[str, Any]) -> None:
await self._store.async_save(data)
@@ -43,24 +152,20 @@ class BCSStorage:
for r in repos:
if not isinstance(r, dict):
continue
rid = str(r.get("id") or "")
url = str(r.get("url") or "")
name = r.get("name")
if rid and url:
out.append(CustomRepo(id=rid, url=url, name=str(name) if name else None))
rid = r.get("id")
url = r.get("url")
if not rid or not url:
continue
out.append(CustomRepo(id=str(rid), url=str(url), name=r.get("name")))
return out
async def add_custom_repo(self, url: str, name: str | None) -> CustomRepo:
data = await self._load()
repos = data.get("custom_repos", [])
# Deduplicate by URL
# De-duplicate by URL
for r in repos:
if isinstance(r, dict) and str(r.get("url", "")).strip() == url.strip():
# Update name if provided
if name:
r["name"] = name
await self._save(data)
if isinstance(r, dict) and str(r.get("url") or "").strip() == url.strip():
return CustomRepo(id=str(r["id"]), url=str(r["url"]), name=r.get("name"))
rid = f"custom:{uuid.uuid4().hex[:10]}"
@@ -73,6 +178,94 @@ class BCSStorage:
async def remove_custom_repo(self, repo_id: str) -> None:
data = await self._load()
repos = data.get("custom_repos", [])
data["custom_repos"] = [r for r in repos if not (isinstance(r, dict) and r.get("id") == repo_id)]
data["custom_repos"] = [
r for r in repos if not (isinstance(r, dict) and r.get("id") == repo_id)
]
await self._save(data)
async def get_installed_repo(self, repo_id: str) -> InstalledRepo | None:
data = await self._load()
installed = data.get("installed_repos", {})
if not isinstance(installed, dict):
return None
entry = installed.get(repo_id)
if not isinstance(entry, dict):
return None
try:
domains = entry.get("domains") or []
if not isinstance(domains, list):
domains = []
domains = [str(d) for d in domains if str(d).strip()]
installed_version = entry.get("installed_version")
ref = entry.get("ref")
# Backward compatibility:
# If installed_version wasn't stored, fall back to ref.
if (not installed_version) and ref:
installed_version = ref
installed_manifest_version = entry.get("installed_manifest_version")
return InstalledRepo(
repo_id=str(entry.get("repo_id") or repo_id),
url=str(entry.get("url") or ""),
domains=domains,
installed_at=int(entry.get("installed_at") or 0),
installed_version=str(installed_version) if installed_version else None,
installed_manifest_version=str(installed_manifest_version) if installed_manifest_version else None,
ref=str(ref) if ref else None,
)
except Exception:
return None
async def list_installed_repos(self) -> list[InstalledRepo]:
data = await self._load()
installed = data.get("installed_repos", {})
out: list[InstalledRepo] = []
if not isinstance(installed, dict):
return out
for rid in list(installed.keys()):
item = await self.get_installed_repo(str(rid))
if item:
out.append(item)
return out
async def set_installed_repo(
self,
*,
repo_id: str,
url: str,
domains: list[str],
installed_version: str | None,
installed_manifest_version: str | None = None,
ref: str | None,
) -> None:
data = await self._load()
installed = data.get("installed_repos", {})
if not isinstance(installed, dict):
installed = {}
data["installed_repos"] = installed
installed[str(repo_id)] = {
"repo_id": str(repo_id),
"url": str(url),
"domains": [str(d) for d in (domains or []) if str(d).strip()],
"installed_at": int(time.time()),
# IMPORTANT: this is what BCS uses as "installed version" (ref/tag/branch)
"installed_version": installed_version,
# informational only
"installed_manifest_version": installed_manifest_version,
# keep ref too (debug/backward compatibility)
"ref": ref,
}
await self._save(data)
async def remove_installed_repo(self, repo_id: str) -> None:
data = await self._load()
installed = data.get("installed_repos", {})
if isinstance(installed, dict) and repo_id in installed:
installed.pop(repo_id, None)
data["installed_repos"] = installed
await self._save(data)

View 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."
}
}
}
}
}

View File

@@ -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)

View File

@@ -16,14 +16,12 @@ _LOGGER = logging.getLogger(__name__)
def _render_markdown_server_side(md: str) -> str | None:
"""Render Markdown -> sanitized HTML (server-side)."""
text = (md or "").strip()
if not text:
return None
html: str | None = None
# 1) python-markdown
try:
import markdown as mdlib # type: ignore
@@ -39,7 +37,6 @@ def _render_markdown_server_side(md: str) -> str | None:
if not html:
return None
# 2) Sanitize via bleach
try:
import bleach # type: ignore
@@ -124,16 +121,6 @@ def _maybe_decode_base64(content: str, encoding: Any) -> str | None:
def _extract_text_recursive(obj: Any, depth: int = 0) -> str | None:
"""
Robust extraction for README markdown.
Handles:
- str / bytes
- dict with:
- {content: "...", encoding: "base64"} (possibly nested)
- {readme: "..."} etc.
- list of dicts (pick first matching)
"""
if obj is None:
return None
@@ -150,21 +137,16 @@ def _extract_text_recursive(obj: Any, depth: int = 0) -> str | None:
return None
if isinstance(obj, dict):
# 1) If it looks like "file content"
content = obj.get("content")
encoding = obj.get("encoding")
# Base64 decode if possible
decoded = _maybe_decode_base64(content, encoding)
if decoded:
return decoded
# content may already be plain text
if isinstance(content, str) and (not isinstance(encoding, str) or not encoding.strip()):
# Heuristic: treat as markdown if it has typical markdown chars, otherwise still return
return content
# 2) direct text keys (readme/markdown/text/body/data)
for k in _TEXT_KEYS:
v = obj.get(k)
if isinstance(v, str):
@@ -175,7 +157,6 @@ def _extract_text_recursive(obj: Any, depth: int = 0) -> str | None:
except Exception:
pass
# 3) Sometimes nested under "file" / "result" / "payload" etc.
for v in obj.values():
out = _extract_text_recursive(v, depth + 1)
if out:
@@ -198,7 +179,7 @@ class StaticAssetsView(HomeAssistantView):
name = "api:bahmcloud_store_static"
requires_auth = False
async def get(self, request: web.Request, path: str) -> web.Response:
async def get(self, request: web.Request, path: str) -> web.StreamResponse:
base = Path(__file__).resolve().parent / "panel"
base_resolved = base.resolve()
@@ -218,24 +199,7 @@ class StaticAssetsView(HomeAssistantView):
_LOGGER.error("BCS static asset not found: %s", target)
return web.Response(status=404)
content_type = "text/plain"
charset = None
if target.suffix == ".js":
content_type = "application/javascript"
charset = "utf-8"
elif target.suffix == ".html":
content_type = "text/html"
charset = "utf-8"
elif target.suffix == ".css":
content_type = "text/css"
charset = "utf-8"
elif target.suffix == ".svg":
content_type = "image/svg+xml"
elif target.suffix == ".png":
content_type = "image/png"
resp = web.Response(body=target.read_bytes(), content_type=content_type, charset=charset)
resp = web.FileResponse(path=target)
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
resp.headers["Pragma"] = "no-cache"
return resp
@@ -247,15 +211,34 @@ class BCSApiView(HomeAssistantView):
requires_auth = True
def __init__(self, core: Any) -> None:
self.core = core
self.core: BCSCore = core
async def get(self, request: web.Request) -> web.Response:
return web.json_response(
{"ok": True, "version": self.core.version, "repos": self.core.list_repos_public()}
{
"ok": True,
"version": self.core.version,
"settings": self.core.get_settings_public(),
"repos": self.core.list_repos_public(),
}
)
async def post(self, request: web.Request) -> web.Response:
data = await request.json()
action = request.query.get("action")
if action == "refresh":
_LOGGER.info("BCS manual refresh triggered via API")
try:
await self.core.full_refresh(source="manual")
return web.json_response({"ok": True})
except Exception as e:
_LOGGER.error("BCS manual refresh failed: %s", e)
return web.json_response({"ok": False, "message": "Refresh failed"}, status=500)
try:
data = await request.json()
except Exception:
data = {}
op = data.get("op")
if op == "add_custom_repo":
@@ -270,13 +253,44 @@ class BCSApiView(HomeAssistantView):
return web.json_response({"ok": False, "message": "Unknown operation"}, status=400)
class BCSSettingsView(HomeAssistantView):
"""Persistent UI settings (e.g. toggles)."""
url = "/api/bcs/settings"
name = "api:bcs_settings"
requires_auth = True
def __init__(self, core: Any) -> None:
self.core: BCSCore = core
async def get(self, request: web.Request) -> web.Response:
return web.json_response({"ok": True, "settings": self.core.get_settings_public()})
async def post(self, request: web.Request) -> web.Response:
try:
data = await request.json()
except Exception:
data = {}
updates: dict[str, Any] = {}
if "hacs_enabled" in data:
updates["hacs_enabled"] = bool(data.get("hacs_enabled"))
try:
settings = await self.core.set_settings(updates)
return web.json_response({"ok": True, "settings": settings})
except Exception as e:
_LOGGER.exception("BCS set settings failed: %s", e)
return web.json_response({"ok": False, "message": str(e) or "Failed"}, status=500)
class BCSCustomRepoView(HomeAssistantView):
url = "/api/bcs/custom_repo"
name = "api:bcs_custom_repo"
requires_auth = True
def __init__(self, core: Any) -> None:
self.core = core
self.core: BCSCore = core
async def delete(self, request: web.Request) -> web.Response:
repo_id = request.query.get("id")
@@ -292,7 +306,7 @@ class BCSReadmeView(HomeAssistantView):
requires_auth = True
def __init__(self, core: Any) -> None:
self.core = core
self.core: BCSCore = core
async def get(self, request: web.Request) -> web.Response:
repo_id = request.query.get("repo_id")
@@ -309,8 +323,210 @@ class BCSReadmeView(HomeAssistantView):
status=404,
)
# Ensure strict JSON string output (avoid accidental objects)
md_str = str(md)
html = _render_markdown_server_side(md_str)
return web.json_response({"ok": True, "readme": md_str, "html": html})
class BCSVersionsView(HomeAssistantView):
url = "/api/bcs/versions"
name = "api:bcs_versions"
requires_auth = True
def __init__(self, core: Any) -> None:
self.core: BCSCore = core
async def get(self, request: web.Request) -> web.Response:
repo_id = request.query.get("repo_id")
if not repo_id:
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
try:
versions = await self.core.list_repo_versions(repo_id)
return web.json_response({"ok": True, "repo_id": repo_id, "versions": versions}, status=200)
except Exception as e:
_LOGGER.exception("BCS list versions failed: %s", e)
return web.json_response({"ok": False, "message": str(e) or "List versions failed"}, status=500)
class BCSInstallView(HomeAssistantView):
url = "/api/bcs/install"
name = "api:bcs_install"
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")
version = request.query.get("version")
if not repo_id:
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
try:
v = str(version).strip() if version is not None else None
result = await self.core.install_repo(repo_id, version=v)
return web.json_response(result, status=200)
except Exception as e:
_LOGGER.exception("BCS install failed: %s", e)
return web.json_response({"ok": False, "message": str(e) or "Install failed"}, status=500)
class BCSUpdateView(HomeAssistantView):
url = "/api/bcs/update"
name = "api:bcs_update"
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")
version = request.query.get("version")
if not repo_id:
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
try:
v = str(version).strip() if version is not None else None
result = await self.core.update_repo(repo_id, version=v)
return web.json_response(result, status=200)
except Exception as e:
_LOGGER.exception("BCS update failed: %s", e)
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 BCSBackupsView(HomeAssistantView):
url = "/api/bcs/backups"
name = "api:bcs_backups"
requires_auth = True
def __init__(self, core: Any) -> None:
self.core: BCSCore = core
async def get(self, request: web.Request) -> web.Response:
repo_id = request.query.get("repo_id")
if not repo_id:
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
try:
backups = await self.core.list_repo_backups(repo_id)
return web.json_response({"ok": True, "repo_id": repo_id, "backups": backups}, status=200)
except Exception as e:
_LOGGER.exception("BCS list backups failed: %s", e)
return web.json_response({"ok": False, "message": str(e) or "List backups failed"}, status=500)
class BCSRestoreView(HomeAssistantView):
url = "/api/bcs/restore"
name = "api:bcs_restore"
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")
backup_id = request.query.get("backup_id")
if not repo_id:
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
if not backup_id:
return web.json_response({"ok": False, "message": "Missing backup_id"}, status=400)
try:
result = await self.core.restore_repo_backup(repo_id, backup_id)
return web.json_response(result, status=200)
except Exception as e:
_LOGGER.exception("BCS restore failed: %s", e)
return web.json_response({"ok": False, "message": str(e) or "Restore failed"}, status=500)
class BCSRestartView(HomeAssistantView):
url = "/api/bcs/restart"
name = "api:bcs_restart"
requires_auth = True
def __init__(self, core: Any) -> None:
self.core: BCSCore = core
async def post(self, request: web.Request) -> web.Response:
try:
await self.core.request_restart()
return web.json_response({"ok": True})
except Exception as e:
_LOGGER.exception("BCS restart failed: %s", e)
return web.json_response({"ok": False, "message": str(e) or "Restart failed"}, status=500)
class BCSRepoDetailView(HomeAssistantView):
url = "/api/bcs/repo"
name = "api:bcs_repo"
requires_auth = True
def __init__(self, core: Any) -> None:
self.core: BCSCore = core
async def get(self, request: web.Request) -> web.Response:
repo_id = (request.query.get("repo_id") or "").strip()
if not repo_id:
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
try:
repo = await self.core.ensure_repo_details(repo_id)
if not repo:
return web.json_response({"ok": False, "message": "Repo not found"}, status=404)
inst = self.core.get_installed(repo_id) or {}
installed = bool(inst)
domains = inst.get("domains") or []
if not isinstance(domains, list):
domains = []
return web.json_response({
"ok": True,
"repo": {
"id": repo.id,
"name": repo.name,
"url": repo.url,
"source": repo.source,
"owner": repo.owner,
"provider": repo.provider,
"repo_name": repo.provider_repo_name,
"description": repo.provider_description or repo.meta_description,
"default_branch": repo.default_branch,
"latest_version": repo.latest_version,
"latest_version_source": repo.latest_version_source,
"category": repo.meta_category,
"meta_author": repo.meta_author,
"meta_maintainer": repo.meta_maintainer,
"meta_source": repo.meta_source,
"installed": installed,
"installed_version": inst.get("installed_version"),
"installed_manifest_version": inst.get("installed_manifest_version"),
"installed_domains": domains,
}
}, status=200)
except Exception as e:
_LOGGER.exception("BCS repo details failed: %s", e)
return web.json_response({"ok": False, "message": str(e) or "Repo details failed"}, status=500)