Compare commits
138 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4012589e6 | |||
| 8ac67fa60c | |||
| 981490c152 | |||
| 99b2a0f0c5 | |||
| 7ead494765 | |||
| 342b6f6c57 | |||
| 66ca63b2be | |||
| e8325f722f | |||
| 7c1a91937a | |||
| 7ac3289bb7 | |||
| 19bdbd1b9a | |||
| 24363cd2ac | |||
| e19ca5bff1 | |||
| 05897d4370 | |||
| 7a3a28d87f | |||
| 240cded8a9 | |||
| 31e241f052 | |||
| de579682a0 | |||
| 9acbd5046c | |||
| 8d63c88e69 | |||
| cffb0af60e | |||
| 857b7a127a | |||
| 66b24ece48 | |||
| 0cc3b466e0 | |||
| f1e03b31a1 | |||
| 4e12d596d6 | |||
| fa97f89afb | |||
| 0718bee185 | |||
| 1a53107450 | |||
| ab82cc6fd3 | |||
| 8e51f144e1 | |||
| f292e22301 | |||
| 2eb194c001 | |||
| f4e367987a | |||
| 08aa4b5e15 | |||
| b1676482f0 | |||
| e46cd6e488 | |||
| edd2fdd3fb | |||
| a4a0c1462b | |||
| 196e63c08e | |||
| 518ac1d59d | |||
| ad699dc69a | |||
| a8e247d288 | |||
| 318d517575 | |||
| db137be5b1 | |||
| 83cec0f75a | |||
| cda9914d50 | |||
| 3acefbfbe8 | |||
| 4d10c5c91e | |||
| 810ff6fe85 | |||
| b2d3d940f2 | |||
| 8b1d828c59 | |||
| 824a9e5cad | |||
| 1cbc204e88 | |||
| 561c323e67 | |||
| 5c604b40c6 | |||
| cc8db6a034 | |||
| e0ad133221 | |||
| 0e27a03aaf | |||
| e2dfa20789 | |||
| 8e8b58d2d2 | |||
| 76ecaabd98 | |||
| 3f14dc3bd9 | |||
| 50a78714cc | |||
| 3bf01c91f1 | |||
| 7aa14284dd | |||
| 24933e980d | |||
| e10624df6b | |||
| 1a1ebd3821 | |||
| d3d61067db | |||
| 23b605becf | |||
| c07f8615e4 | |||
| 9b209a15bf | |||
| 30258bd2c0 | |||
| 2c8ca490ea | |||
| 9e8a8e81b9 | |||
| f5b2534fdb | |||
| 8b3916c3fa | |||
| 13e71046f8 | |||
| 58e3674325 | |||
| 828d84caa3 | |||
| c18e93406a | |||
| 9af18ba090 | |||
| fff50a1580 | |||
| f8e9967c3a | |||
| 7bc493eb45 | |||
| b97b970a45 | |||
| 593e0c367d | |||
| 8e0817a64b | |||
| dfc7e44565 | |||
| c9c4f99fbf | |||
| 37cc11c9ee | |||
| 9c773c07e8 | |||
| c04612e159 | |||
| 5796012189 | |||
| 01576153d8 | |||
| 30484a08c1 | |||
| faf122aa1c | |||
| 1e86df49e9 | |||
| df631eec9e | |||
| 07240d1268 | |||
| 50587ffbbd | |||
| d6347e7e59 | |||
| 870e77ec13 | |||
| 38fb9fb073 | |||
| c20bd4dd07 | |||
| 296c816633 | |||
| 18a2b5529c | |||
| 246fab7e1e | |||
| ce5802721f | |||
| 2f46966fe2 | |||
| 132f9e27c1 | |||
| 618511be73 | |||
| 6488b434d8 | |||
| bffc594da5 | |||
| d78217100c | |||
| 09e1ef1af5 | |||
| 9ad558c9ab | |||
| 19df0eea22 | |||
| 745979b9a6 | |||
| f861b2490a | |||
| 32946c1a98 | |||
| a9a681d801 | |||
| 2ae6ac43a5 | |||
| 504c126c2c | |||
| 85cc97b557 | |||
| 4ca80a9c88 | |||
| ac5bc8a6f4 | |||
| c4361cc8bd | |||
| 1794d579d2 | |||
| bcfbf7151c | |||
| 38730cdd31 | |||
| 5d5d78d727 | |||
| 67297bfc9c | |||
| 82fda5dfc4 | |||
| 907f14b73c | |||
| 3eefd447ac | |||
| 72ce95525c |
239
CHANGELOG.md
239
CHANGELOG.md
@@ -11,19 +11,238 @@ Sections:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [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.
|
||||||
|
- Added support for nested GitLab groups when resolving README paths.
|
||||||
|
- Added fallback handling for multiple README filenames (`README.md`, `README`, `README.rst`, etc.).
|
||||||
|
- Added branch fallback logic for README loading (`default`, `main`, `master`).
|
||||||
|
- Improved error resilience so README loading failures never break the store core.
|
||||||
|
- No behavior change for GitHub and Gitea providers.
|
||||||
|
|
||||||
## [0.4.0] - 2026-01-15
|
## [0.4.0] - 2026-01-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Repository detail view (second page) in the Store UI.
|
- Initial public release of the Bahmcloud Store integration.
|
||||||
- README rendering using Home Assistant's `ha-markdown` element.
|
- Sidebar panel with repository browser UI.
|
||||||
- Floating action buttons (FAB):
|
- Support for loading repositories from a central `store.yaml` index.
|
||||||
- Open repository
|
- Support for custom repositories added by the user.
|
||||||
- Reload README
|
- Provider abstraction for GitHub, GitLab and Gitea:
|
||||||
- Install (coming soon)
|
- Fetch repository information (name, description, default branch).
|
||||||
- Update (coming soon)
|
- Resolve latest version from:
|
||||||
- Search field and category filter on the repository list page.
|
- Releases
|
||||||
- New authenticated API endpoint:
|
- Tags
|
||||||
- `GET /api/bcs/readme?repo_id=<id>` returns README markdown (best-effort).
|
- Fallback mechanisms.
|
||||||
|
- Repository metadata support via:
|
||||||
|
- `bcs.yaml`
|
||||||
|
- `hacs.yaml`
|
||||||
|
- `hacs.json`
|
||||||
|
- README loading and rendering pipeline:
|
||||||
|
- Fetch raw README files.
|
||||||
|
- Server-side Markdown rendering.
|
||||||
|
- Sanitized HTML output for the panel UI.
|
||||||
|
- Auto refresh mechanism for store index and repository metadata.
|
||||||
|
- API endpoints:
|
||||||
|
- List repositories
|
||||||
|
- Add custom repository
|
||||||
|
- Remove repository
|
||||||
|
Persisted via Home Assistant storage (`.storage/bcs_store`).
|
||||||
|
- Public static asset endpoint for panel JS (`/api/bahmcloud_store_static/...`) without auth (required for HA custom panels).
|
||||||
|
- Initial API namespace:
|
||||||
|
- `GET /api/bcs` list merged repositories (index + custom)
|
||||||
|
- `POST /api/bcs` add custom repository
|
||||||
|
- `DELETE /api/bcs/custom_repo` remove custom repository
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Repository cards are now clickable to open the detail view.
|
- Repository cards are now clickable to open the detail view.
|
||||||
|
|||||||
19
bcs.yaml
Normal file
19
bcs.yaml
Normal 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
|
||||||
@@ -4,10 +4,10 @@ import logging
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.const import Platform
|
|
||||||
from homeassistant.helpers.discovery import async_load_platform
|
|
||||||
from homeassistant.helpers.event import async_track_time_interval
|
|
||||||
from homeassistant.components.panel_custom import async_register_panel
|
from homeassistant.components.panel_custom import async_register_panel
|
||||||
|
from homeassistant.helpers.event import async_track_time_interval, async_call_later
|
||||||
|
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
|
||||||
|
from homeassistant.helpers.discovery import async_load_platform
|
||||||
|
|
||||||
from .core import BCSCore, BCSConfig, BCSError
|
from .core import BCSCore, BCSConfig, BCSError
|
||||||
|
|
||||||
@@ -20,40 +20,81 @@ CONF_STORE_URL = "store_url"
|
|||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
||||||
cfg = config.get(DOMAIN, {})
|
cfg = config.get(DOMAIN, {}) or {}
|
||||||
store_url = cfg.get(CONF_STORE_URL, DEFAULT_STORE_URL)
|
store_url = cfg.get(CONF_STORE_URL, DEFAULT_STORE_URL)
|
||||||
|
|
||||||
core = BCSCore(hass, BCSConfig(store_url=store_url))
|
core = BCSCore(hass, BCSConfig(store_url=store_url))
|
||||||
hass.data[DOMAIN] = core
|
hass.data[DOMAIN] = core
|
||||||
|
|
||||||
await core.register_http_views()
|
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(
|
await async_register_panel(
|
||||||
hass,
|
hass,
|
||||||
frontend_url_path="bahmcloud-store",
|
frontend_url_path="bahmcloud-store",
|
||||||
webcomponent_name="bahmcloud-store-panel",
|
webcomponent_name="bahmcloud-store-panel",
|
||||||
module_url="/api/bahmcloud_store_static/panel.js?v=42",
|
# IMPORTANT: bump v to avoid caching old JS
|
||||||
|
module_url="/api/bahmcloud_store_static/panel.js?v=108",
|
||||||
sidebar_title="Bahmcloud Store",
|
sidebar_title="Bahmcloud Store",
|
||||||
sidebar_icon="mdi:store",
|
sidebar_icon="mdi:store",
|
||||||
require_admin=True,
|
require_admin=True,
|
||||||
config={},
|
config={},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _do_startup_refresh(_now=None) -> None:
|
||||||
try:
|
try:
|
||||||
await core.refresh()
|
await core.full_refresh(source="startup")
|
||||||
except BCSError as e:
|
except BCSError as e:
|
||||||
_LOGGER.error("Initial refresh failed: %s", e)
|
_LOGGER.error("Initial refresh failed: %s", e)
|
||||||
|
|
||||||
|
# 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:
|
async def periodic(_now) -> None:
|
||||||
try:
|
try:
|
||||||
await core.refresh()
|
await core.full_refresh(source="timer")
|
||||||
core.signal_updated()
|
|
||||||
except BCSError as e:
|
except BCSError as e:
|
||||||
_LOGGER.warning("Periodic refresh failed: %s", e)
|
_LOGGER.warning("Periodic refresh failed: %s", e)
|
||||||
|
except Exception as e: # pylint: disable=broad-exception-caught
|
||||||
|
_LOGGER.exception("Unexpected error during periodic refresh: %s", e)
|
||||||
|
|
||||||
interval = timedelta(seconds=int(core.refresh_seconds or 300))
|
interval_seconds = int(getattr(core, "refresh_seconds", 300) or 300)
|
||||||
async_track_time_interval(hass, periodic, interval)
|
async_track_time_interval(hass, periodic, timedelta(seconds=interval_seconds))
|
||||||
|
|
||||||
await async_load_platform(hass, Platform.UPDATE, DOMAIN, {}, config)
|
|
||||||
return True
|
return True
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,10 @@
|
|||||||
{
|
{
|
||||||
"domain": "bahmcloud_store",
|
"domain": "bahmcloud_store",
|
||||||
"name": "Bahmcloud Store",
|
"name": "Bahmcloud Store",
|
||||||
"version": "0.4.0",
|
"version": "0.6.6",
|
||||||
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
|
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
|
||||||
|
"platforms": ["update"],
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"codeowners": [],
|
"codeowners": ["@bahmcloud"],
|
||||||
"iot_class": "local_polling"
|
"iot_class": "local_polling"
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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; }
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import xml.etree.ElementTree as ET
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from urllib.parse import quote_plus, urlparse
|
from urllib.parse import quote_plus, urlparse
|
||||||
|
|
||||||
|
from packaging.version import InvalidVersion, Version
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
@@ -51,13 +53,8 @@ def detect_provider(repo_url: str) -> str:
|
|||||||
return "github"
|
return "github"
|
||||||
if "gitlab" in host:
|
if "gitlab" in host:
|
||||||
return "gitlab"
|
return "gitlab"
|
||||||
|
|
||||||
owner, repo = _split_owner_repo(repo_url)
|
|
||||||
if owner and repo:
|
|
||||||
return "gitea"
|
return "gitea"
|
||||||
|
|
||||||
return "generic"
|
|
||||||
|
|
||||||
|
|
||||||
async def _safe_json(session, url: str, *, headers: dict | None = None, timeout: int = 20):
|
async def _safe_json(session, url: str, *, headers: dict | None = None, timeout: int = 20):
|
||||||
try:
|
try:
|
||||||
@@ -82,130 +79,113 @@ async def _safe_text(session, url: str, *, headers: dict | None = None, timeout:
|
|||||||
|
|
||||||
|
|
||||||
def _extract_tag_from_github_url(url: str) -> str | None:
|
def _extract_tag_from_github_url(url: str) -> str | None:
|
||||||
m = re.search(r"/releases/tag/([^/?#]+)", url)
|
m = re.search(r"/releases/tag/([^/?#]+)", url or "")
|
||||||
if m:
|
if not m:
|
||||||
return m.group(1)
|
|
||||||
m = re.search(r"/tag/([^/?#]+)", url)
|
|
||||||
if m:
|
|
||||||
return m.group(1)
|
|
||||||
return None
|
return None
|
||||||
|
return m.group(1).strip() or None
|
||||||
|
|
||||||
def _strip_html(s: str) -> str:
|
|
||||||
# minimal HTML entity cleanup for meta descriptions
|
|
||||||
out = (
|
|
||||||
s.replace("&", "&")
|
|
||||||
.replace(""", '"')
|
|
||||||
.replace("'", "'")
|
|
||||||
.replace("<", "<")
|
|
||||||
.replace(">", ">")
|
|
||||||
)
|
|
||||||
return re.sub(r"\s+", " ", out).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_meta(html: str, *, prop: str | None = None, name: str | None = None) -> str | None:
|
def _extract_meta(html: str, *, prop: str | None = None, name: str | None = None) -> str | None:
|
||||||
# Extract <meta property="og:description" content="...">
|
if not html:
|
||||||
# or <meta name="description" content="...">
|
return None
|
||||||
if prop:
|
if prop:
|
||||||
# property="..." content="..."
|
m = re.search(rf'<meta\s+property="{re.escape(prop)}"\s+content="([^"]+)"', html)
|
||||||
m = re.search(
|
|
||||||
r'<meta[^>]+property=["\']' + re.escape(prop) + r'["\'][^>]+content=["\']([^"\']+)["\']',
|
|
||||||
html,
|
|
||||||
flags=re.IGNORECASE,
|
|
||||||
)
|
|
||||||
if m:
|
if m:
|
||||||
return _strip_html(m.group(1))
|
return m.group(1).strip()
|
||||||
m = re.search(
|
|
||||||
r'<meta[^>]+content=["\']([^"\']+)["\'][^>]+property=["\']' + re.escape(prop) + r'["\']',
|
|
||||||
html,
|
|
||||||
flags=re.IGNORECASE,
|
|
||||||
)
|
|
||||||
if m:
|
|
||||||
return _strip_html(m.group(1))
|
|
||||||
|
|
||||||
if name:
|
if name:
|
||||||
m = re.search(
|
m = re.search(rf'<meta\s+name="{re.escape(name)}"\s+content="([^"]+)"', html)
|
||||||
r'<meta[^>]+name=["\']' + re.escape(name) + r'["\'][^>]+content=["\']([^"\']+)["\']',
|
|
||||||
html,
|
|
||||||
flags=re.IGNORECASE,
|
|
||||||
)
|
|
||||||
if m:
|
if m:
|
||||||
return _strip_html(m.group(1))
|
return m.group(1).strip()
|
||||||
m = re.search(
|
|
||||||
r'<meta[^>]+content=["\']([^"\']+)["\'][^>]+name=["\']' + re.escape(name) + r'["\']',
|
|
||||||
html,
|
|
||||||
flags=re.IGNORECASE,
|
|
||||||
)
|
|
||||||
if m:
|
|
||||||
return _strip_html(m.group(1))
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _github_description_html(hass: HomeAssistant, owner: str, repo: str) -> str | None:
|
def _semver_key(tag: str) -> Version | None:
|
||||||
"""
|
t = (tag or "").strip()
|
||||||
GitHub API may be rate-limited; fetch public HTML and read meta description.
|
if not t:
|
||||||
"""
|
return None
|
||||||
session = async_get_clientsession(hass)
|
if t.startswith(("v", "V")):
|
||||||
headers = {
|
t = t[1:]
|
||||||
"User-Agent": UA,
|
try:
|
||||||
"Accept": "text/html,application/xhtml+xml",
|
return Version(t)
|
||||||
}
|
except InvalidVersion:
|
||||||
|
return None
|
||||||
|
|
||||||
html, status = await _safe_text(session, f"https://github.com/{owner}/{repo}", headers=headers)
|
|
||||||
if not html or status != 200:
|
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}"
|
||||||
|
html, status = await _safe_text(session, url, headers={"User-Agent": UA})
|
||||||
|
if status != 200 or not html:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
desc = _extract_meta(html, prop="og:description")
|
desc = _extract_meta(html, prop="og:description")
|
||||||
if desc:
|
if desc:
|
||||||
return desc
|
return desc
|
||||||
|
|
||||||
desc = _extract_meta(html, name="description")
|
return _extract_meta(html, name="description")
|
||||||
if desc:
|
|
||||||
return desc
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _github_latest_version_atom(hass: HomeAssistant, owner: str, repo: str) -> tuple[str | None, str | None]:
|
async def _github_latest_version_atom(hass: HomeAssistant, owner: str, repo: str) -> tuple[str | None, str | None]:
|
||||||
session = async_get_clientsession(hass)
|
session = async_get_clientsession(hass)
|
||||||
headers = {"User-Agent": UA, "Accept": "application/atom+xml,text/xml;q=0.9,*/*;q=0.8"}
|
url = f"https://github.com/{owner}/{repo}/releases.atom"
|
||||||
|
atom, status = await _safe_text(session, url, headers={"User-Agent": UA})
|
||||||
xml_text, _ = await _safe_text(session, f"https://github.com/{owner}/{repo}/releases.atom", headers=headers)
|
if status != 200 or not atom:
|
||||||
if not xml_text:
|
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
root = ET.fromstring(xml_text)
|
root = ET.fromstring(atom)
|
||||||
except Exception:
|
ns = {"a": "http://www.w3.org/2005/Atom"}
|
||||||
|
entry = root.find("a:entry", ns)
|
||||||
|
if entry is None:
|
||||||
return None, None
|
return None, None
|
||||||
|
link = entry.find("a:link", ns)
|
||||||
for entry in root.findall(".//{*}entry"):
|
if link is not None and link.attrib.get("href"):
|
||||||
for link in entry.findall(".//{*}link"):
|
tag = _extract_tag_from_github_url(link.attrib["href"])
|
||||||
href = link.attrib.get("href")
|
|
||||||
if not href:
|
|
||||||
continue
|
|
||||||
tag = _extract_tag_from_github_url(href)
|
|
||||||
if tag:
|
if tag:
|
||||||
return tag, "atom"
|
return tag, "atom"
|
||||||
|
title = entry.find("a:title", ns)
|
||||||
|
if title is not None and title.text:
|
||||||
|
t = title.text.strip()
|
||||||
|
if t:
|
||||||
|
return t, "atom"
|
||||||
|
except Exception:
|
||||||
|
return None, None
|
||||||
|
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
async def _github_latest_version_redirect(hass: HomeAssistant, owner: str, repo: str) -> tuple[str | None, str | None]:
|
async def _github_latest_version_redirect(hass: HomeAssistant, owner: str, repo: str) -> tuple[str | None, str | None]:
|
||||||
session = async_get_clientsession(hass)
|
session = async_get_clientsession(hass)
|
||||||
headers = {"User-Agent": UA}
|
|
||||||
url = f"https://github.com/{owner}/{repo}/releases/latest"
|
url = f"https://github.com/{owner}/{repo}/releases/latest"
|
||||||
try:
|
try:
|
||||||
async with session.head(url, allow_redirects=False, timeout=15, headers=headers) as resp:
|
async with session.get(url, timeout=20, headers={"User-Agent": UA}, allow_redirects=True) as resp:
|
||||||
if resp.status in (301, 302, 303, 307, 308):
|
if resp.status != 200:
|
||||||
loc = resp.headers.get("Location")
|
return None, None
|
||||||
if loc:
|
final = str(resp.url)
|
||||||
tag = _extract_tag_from_github_url(loc)
|
tag = _extract_tag_from_github_url(final)
|
||||||
if tag:
|
if tag:
|
||||||
return tag, "release"
|
return tag, "release"
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
return None, None
|
||||||
|
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
@@ -213,75 +193,125 @@ async def _github_latest_version_api(hass: HomeAssistant, owner: str, repo: str)
|
|||||||
session = async_get_clientsession(hass)
|
session = async_get_clientsession(hass)
|
||||||
headers = {"Accept": "application/vnd.github+json", "User-Agent": UA}
|
headers = {"Accept": "application/vnd.github+json", "User-Agent": UA}
|
||||||
|
|
||||||
data, _ = await _safe_json(session, f"https://api.github.com/repos/{owner}/{repo}/releases/latest", headers=headers)
|
data, status = await _safe_json(session, f"https://api.github.com/repos/{owner}/{repo}/releases/latest", headers=headers)
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict) and data.get("tag_name"):
|
||||||
tag = data.get("tag_name") or data.get("name")
|
return str(data["tag_name"]), "release"
|
||||||
if isinstance(tag, str) and tag.strip():
|
|
||||||
return tag.strip(), "release"
|
|
||||||
|
|
||||||
data, _ = await _safe_json(session, f"https://api.github.com/repos/{owner}/{repo}/tags?per_page=1", headers=headers)
|
# No releases -> pick highest semver from many tags (instead of per_page=1)
|
||||||
if isinstance(data, list) and data:
|
if status == 404:
|
||||||
tag = data[0].get("name")
|
data, _ = await _safe_json(session, f"https://api.github.com/repos/{owner}/{repo}/tags?per_page=100", headers=headers)
|
||||||
if isinstance(tag, str) and tag.strip():
|
tags: list[str] = []
|
||||||
return tag.strip(), "tag"
|
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
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
async def _github_latest_version(hass: HomeAssistant, owner: str, repo: str) -> tuple[str | None, str | None]:
|
async def _github_latest_version(hass: HomeAssistant, owner: str, repo: str) -> tuple[str | None, str | None]:
|
||||||
tag, src = await _github_latest_version_atom(hass, owner, repo)
|
|
||||||
if tag:
|
|
||||||
return tag, src
|
|
||||||
|
|
||||||
tag, src = await _github_latest_version_redirect(hass, owner, repo)
|
tag, src = await _github_latest_version_redirect(hass, owner, repo)
|
||||||
if tag:
|
if tag:
|
||||||
return tag, src
|
return tag, src
|
||||||
|
|
||||||
return await _github_latest_version_api(hass, owner, repo)
|
tag, src = await _github_latest_version_api(hass, owner, repo)
|
||||||
|
if tag:
|
||||||
|
return tag, src
|
||||||
|
|
||||||
|
return await _github_latest_version_atom(hass, owner, repo)
|
||||||
|
|
||||||
|
|
||||||
async def _gitea_latest_version(hass: HomeAssistant, base: str, owner: str, repo: str) -> tuple[str | None, str | None]:
|
async def _gitea_latest_version(hass: HomeAssistant, base: str, owner: str, repo: str) -> tuple[str | None, str | None]:
|
||||||
session = async_get_clientsession(hass)
|
session = async_get_clientsession(hass)
|
||||||
|
|
||||||
data, _ = await _safe_json(session, f"{base}/api/v1/repos/{owner}/{repo}/releases?limit=1")
|
# releases: fetch multiple, pick highest semver (instead of limit=1)
|
||||||
if isinstance(data, list) and data:
|
data, _ = await _safe_json(session, f"{base}/api/v1/repos/{owner}/{repo}/releases?limit=50")
|
||||||
tag = data[0].get("tag_name") or data[0].get("name")
|
rel_tags: list[str] = []
|
||||||
if isinstance(tag, str) and tag.strip():
|
if isinstance(data, list):
|
||||||
return tag.strip(), "release"
|
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")
|
best_rel = _pick_highest_semver(rel_tags)
|
||||||
if isinstance(data, list) and data:
|
if best_rel:
|
||||||
tag = data[0].get("name")
|
return best_rel, "release"
|
||||||
if isinstance(tag, str) and tag.strip():
|
if rel_tags:
|
||||||
return tag.strip(), "tag"
|
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
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
async def _gitlab_latest_version(hass: HomeAssistant, base: str, owner: str, repo: str) -> tuple[str | None, str | None]:
|
async def _gitlab_latest_version(
|
||||||
|
hass: HomeAssistant, base: str, owner: str, repo: str
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
session = async_get_clientsession(hass)
|
session = async_get_clientsession(hass)
|
||||||
headers = {"User-Agent": UA}
|
headers = {"User-Agent": UA}
|
||||||
|
|
||||||
project = quote_plus(f"{owner}/{repo}")
|
project = quote_plus(f"{owner}/{repo}")
|
||||||
|
|
||||||
data, _ = await _safe_json(
|
# releases: fetch multiple, pick highest semver (instead of per_page=1)
|
||||||
session,
|
data, _ = await _safe_json(session, f"{base}/api/v4/projects/{project}/releases?per_page=50", headers=headers)
|
||||||
f"{base}/api/v4/projects/{project}/releases?per_page=1&order_by=released_at&sort=desc",
|
rel_tags: list[str] = []
|
||||||
headers=headers,
|
if isinstance(data, list):
|
||||||
)
|
for r in data:
|
||||||
if isinstance(data, list) and data:
|
if isinstance(r, dict) and r.get("tag_name"):
|
||||||
tag = data[0].get("tag_name") or data[0].get("name")
|
rel_tags.append(str(r["tag_name"]))
|
||||||
if isinstance(tag, str) and tag.strip():
|
|
||||||
return tag.strip(), "release"
|
|
||||||
|
|
||||||
data, _ = await _safe_json(
|
best_rel = _pick_highest_semver(rel_tags)
|
||||||
session,
|
if best_rel:
|
||||||
f"{base}/api/v4/projects/{project}/repository/tags?per_page=1&order_by=updated&sort=desc",
|
return best_rel, "release"
|
||||||
headers=headers,
|
if rel_tags:
|
||||||
)
|
return rel_tags[0], "release"
|
||||||
if isinstance(data, list) and data:
|
|
||||||
tag = data[0].get("name")
|
# tags: fetch multiple, pick highest semver (instead of per_page=1)
|
||||||
if isinstance(tag, str) and tag.strip():
|
data, _ = await _safe_json(session, f"{base}/api/v4/projects/{project}/repository/tags?per_page=50", headers=headers)
|
||||||
return tag.strip(), "tag"
|
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:
|
||||||
|
root = ET.fromstring(atom)
|
||||||
|
ns = {"a": "http://www.w3.org/2005/Atom"}
|
||||||
|
entry = root.find("a:entry", ns)
|
||||||
|
if entry is not None:
|
||||||
|
title = entry.find("a:title", ns)
|
||||||
|
if title is not None and title.text:
|
||||||
|
return title.text.strip(), "atom"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
@@ -307,7 +337,6 @@ async def fetch_repo_info(hass: HomeAssistant, repo_url: str) -> RepoInfo:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if provider == "github":
|
if provider == "github":
|
||||||
# Try API repo details (may be rate-limited)
|
|
||||||
headers = {"Accept": "application/vnd.github+json", "User-Agent": UA}
|
headers = {"Accept": "application/vnd.github+json", "User-Agent": UA}
|
||||||
data, status = await _safe_json(session, f"https://api.github.com/repos/{owner}/{repo}", headers=headers)
|
data, status = await _safe_json(session, f"https://api.github.com/repos/{owner}/{repo}", headers=headers)
|
||||||
|
|
||||||
@@ -318,12 +347,10 @@ async def fetch_repo_info(hass: HomeAssistant, repo_url: str) -> RepoInfo:
|
|||||||
if isinstance(data.get("owner"), dict) and data["owner"].get("login"):
|
if isinstance(data.get("owner"), dict) and data["owner"].get("login"):
|
||||||
info.owner = data["owner"]["login"]
|
info.owner = data["owner"]["login"]
|
||||||
else:
|
else:
|
||||||
# If API blocked, still set reasonable defaults
|
|
||||||
if status == 403:
|
if status == 403:
|
||||||
_LOGGER.debug("GitHub API blocked/rate-limited for repo info %s/%s", owner, repo)
|
_LOGGER.debug("GitHub API blocked/rate-limited for repo info %s/%s", owner, repo)
|
||||||
info.default_branch = "main"
|
info.default_branch = "main"
|
||||||
|
|
||||||
# If description missing, fetch from GitHub HTML
|
|
||||||
if not info.description:
|
if not info.description:
|
||||||
desc = await _github_description_html(hass, owner, repo)
|
desc = await _github_description_html(hass, owner, repo)
|
||||||
if desc:
|
if desc:
|
||||||
@@ -371,8 +398,266 @@ async def fetch_repo_info(hass: HomeAssistant, repo_url: str) -> RepoInfo:
|
|||||||
info.latest_version_source = src
|
info.latest_version_source = src
|
||||||
return info
|
return info
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_LOGGER.debug("fetch_repo_info failed for %s: %s", repo_url, e)
|
||||||
|
|
||||||
return info
|
return info
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
_LOGGER.debug("Provider fetch failed for %s: %s", repo_url, e)
|
async def fetch_readme_markdown(
|
||||||
return info
|
hass: HomeAssistant,
|
||||||
|
repo_url: str,
|
||||||
|
*,
|
||||||
|
provider: str | None = None,
|
||||||
|
default_branch: str | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Fetch README Markdown for public repositories (GitHub/GitLab/Gitea).
|
||||||
|
|
||||||
|
Defensive behavior:
|
||||||
|
- tries multiple common README filenames
|
||||||
|
- tries multiple branches (default, main, master)
|
||||||
|
- uses public raw endpoints (no tokens required for public repositories)
|
||||||
|
"""
|
||||||
|
repo_url = (repo_url or "").strip()
|
||||||
|
if not repo_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
prov = (provider or "").strip().lower() if provider else ""
|
||||||
|
if not prov:
|
||||||
|
prov = detect_provider(repo_url)
|
||||||
|
|
||||||
|
branch_candidates: list[str] = []
|
||||||
|
if default_branch and str(default_branch).strip():
|
||||||
|
branch_candidates.append(str(default_branch).strip())
|
||||||
|
for b in ("main", "master"):
|
||||||
|
if b not in branch_candidates:
|
||||||
|
branch_candidates.append(b)
|
||||||
|
|
||||||
|
filenames = ["README.md", "readme.md", "README.MD", "README.rst", "README"]
|
||||||
|
|
||||||
|
session = async_get_clientsession(hass)
|
||||||
|
headers = {"User-Agent": UA}
|
||||||
|
|
||||||
|
def _normalize_gitlab_path(path: str) -> str | None:
|
||||||
|
p = (path or "").strip().strip("/")
|
||||||
|
if not p:
|
||||||
|
return None
|
||||||
|
parts = [x for x in p.split("/") if x]
|
||||||
|
if len(parts) < 2:
|
||||||
|
return None
|
||||||
|
if parts[-1].endswith(".git"):
|
||||||
|
parts[-1] = parts[-1][:-4]
|
||||||
|
return "/".join(parts)
|
||||||
|
|
||||||
|
candidates: list[str] = []
|
||||||
|
|
||||||
|
if prov == "github":
|
||||||
|
owner, repo = _split_owner_repo(repo_url)
|
||||||
|
if not owner or not repo:
|
||||||
|
return None
|
||||||
|
for branch in branch_candidates:
|
||||||
|
base = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}"
|
||||||
|
for fn in filenames:
|
||||||
|
candidates.append(f"{base}/{fn}")
|
||||||
|
|
||||||
|
elif prov == "gitea":
|
||||||
|
owner, repo = _split_owner_repo(repo_url)
|
||||||
|
if not owner or not repo:
|
||||||
|
return None
|
||||||
|
u = urlparse(repo_url.rstrip("/"))
|
||||||
|
root = f"{u.scheme}://{u.netloc}/{owner}/{repo}"
|
||||||
|
for branch in branch_candidates:
|
||||||
|
bases = [
|
||||||
|
f"{root}/raw/branch/{branch}",
|
||||||
|
f"{root}/raw/{branch}",
|
||||||
|
]
|
||||||
|
for b in bases:
|
||||||
|
for fn in filenames:
|
||||||
|
candidates.append(f"{b}/{fn}")
|
||||||
|
|
||||||
|
elif prov == "gitlab":
|
||||||
|
u = urlparse(repo_url.rstrip("/"))
|
||||||
|
path_repo = _normalize_gitlab_path(u.path)
|
||||||
|
if not path_repo:
|
||||||
|
return None
|
||||||
|
root = f"{u.scheme}://{u.netloc}/{path_repo}"
|
||||||
|
for branch in branch_candidates:
|
||||||
|
bases = [
|
||||||
|
f"{root}/-/raw/{branch}",
|
||||||
|
f"{root}/raw/{branch}",
|
||||||
|
]
|
||||||
|
for b in bases:
|
||||||
|
for fn in filenames:
|
||||||
|
candidates.append(f"{b}/{fn}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for url in candidates:
|
||||||
|
try:
|
||||||
|
async with session.get(url, timeout=20, headers=headers) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
continue
|
||||||
|
txt = await resp.text()
|
||||||
|
if txt and txt.strip():
|
||||||
|
return txt
|
||||||
|
except Exception:
|
||||||
|
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
|
||||||
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
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -18,21 +19,90 @@ class CustomRepo:
|
|||||||
name: str | None = None
|
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:
|
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)
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant) -> None:
|
def __init__(self, hass: HomeAssistant) -> None:
|
||||||
self.hass = hass
|
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]:
|
async def _load(self) -> dict[str, Any]:
|
||||||
data = await self._store.async_load()
|
data = await self._store.async_load() or {}
|
||||||
if not data:
|
if not isinstance(data, dict):
|
||||||
return {"custom_repos": []}
|
data = {}
|
||||||
if "custom_repos" not in data:
|
|
||||||
|
if "custom_repos" not in data or not isinstance(data.get("custom_repos"), list):
|
||||||
data["custom_repos"] = []
|
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"] = {}
|
||||||
|
|
||||||
return data
|
return 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:
|
async def _save(self, data: dict[str, Any]) -> None:
|
||||||
await self._store.async_save(data)
|
await self._store.async_save(data)
|
||||||
|
|
||||||
@@ -43,24 +113,20 @@ class BCSStorage:
|
|||||||
for r in repos:
|
for r in repos:
|
||||||
if not isinstance(r, dict):
|
if not isinstance(r, dict):
|
||||||
continue
|
continue
|
||||||
rid = str(r.get("id") or "")
|
rid = r.get("id")
|
||||||
url = str(r.get("url") or "")
|
url = r.get("url")
|
||||||
name = r.get("name")
|
if not rid or not url:
|
||||||
if rid and url:
|
continue
|
||||||
out.append(CustomRepo(id=rid, url=url, name=str(name) if name else None))
|
out.append(CustomRepo(id=str(rid), url=str(url), name=r.get("name")))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
async def add_custom_repo(self, url: str, name: str | None) -> CustomRepo:
|
async def add_custom_repo(self, url: str, name: str | None) -> CustomRepo:
|
||||||
data = await self._load()
|
data = await self._load()
|
||||||
repos = data.get("custom_repos", [])
|
repos = data.get("custom_repos", [])
|
||||||
|
|
||||||
# Deduplicate by URL
|
# De-duplicate by URL
|
||||||
for r in repos:
|
for r in repos:
|
||||||
if isinstance(r, dict) and str(r.get("url", "")).strip() == url.strip():
|
if isinstance(r, dict) and str(r.get("url") or "").strip() == url.strip():
|
||||||
# Update name if provided
|
|
||||||
if name:
|
|
||||||
r["name"] = name
|
|
||||||
await self._save(data)
|
|
||||||
return CustomRepo(id=str(r["id"]), url=str(r["url"]), name=r.get("name"))
|
return CustomRepo(id=str(r["id"]), url=str(r["url"]), name=r.get("name"))
|
||||||
|
|
||||||
rid = f"custom:{uuid.uuid4().hex[:10]}"
|
rid = f"custom:{uuid.uuid4().hex[:10]}"
|
||||||
@@ -73,6 +139,94 @@ class BCSStorage:
|
|||||||
async def remove_custom_repo(self, repo_id: str) -> None:
|
async def remove_custom_repo(self, repo_id: str) -> None:
|
||||||
data = await self._load()
|
data = await self._load()
|
||||||
repos = data.get("custom_repos", [])
|
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)
|
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)
|
||||||
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
|
from __future__ import annotations
|
||||||
|
|
||||||
# NOTE:
|
import logging
|
||||||
# Update entities will be implemented once installation/provider resolution is in place.
|
from dataclasses import dataclass
|
||||||
# This stub prevents platform load errors and keeps the integration stable in 0.3.0.
|
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_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(
|
async def async_setup_platform(
|
||||||
@@ -14,4 +145,18 @@ async def async_setup_platform(
|
|||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
discovery_info=None,
|
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
|
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)
|
||||||
@@ -16,14 +16,12 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
def _render_markdown_server_side(md: str) -> str | None:
|
def _render_markdown_server_side(md: str) -> str | None:
|
||||||
"""Render Markdown -> sanitized HTML (server-side)."""
|
|
||||||
text = (md or "").strip()
|
text = (md or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
html: str | None = None
|
html: str | None = None
|
||||||
|
|
||||||
# 1) python-markdown
|
|
||||||
try:
|
try:
|
||||||
import markdown as mdlib # type: ignore
|
import markdown as mdlib # type: ignore
|
||||||
|
|
||||||
@@ -39,7 +37,6 @@ def _render_markdown_server_side(md: str) -> str | None:
|
|||||||
if not html:
|
if not html:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# 2) Sanitize via bleach
|
|
||||||
try:
|
try:
|
||||||
import bleach # type: ignore
|
import bleach # type: ignore
|
||||||
|
|
||||||
@@ -124,16 +121,6 @@ def _maybe_decode_base64(content: str, encoding: Any) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def _extract_text_recursive(obj: Any, depth: int = 0) -> str | None:
|
def _extract_text_recursive(obj: Any, depth: int = 0) -> str | None:
|
||||||
"""
|
|
||||||
Robust extraction for README markdown.
|
|
||||||
|
|
||||||
Handles:
|
|
||||||
- str / bytes
|
|
||||||
- dict with:
|
|
||||||
- {content: "...", encoding: "base64"} (possibly nested)
|
|
||||||
- {readme: "..."} etc.
|
|
||||||
- list of dicts (pick first matching)
|
|
||||||
"""
|
|
||||||
if obj is None:
|
if obj is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -150,21 +137,16 @@ def _extract_text_recursive(obj: Any, depth: int = 0) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
if isinstance(obj, dict):
|
if isinstance(obj, dict):
|
||||||
# 1) If it looks like "file content"
|
|
||||||
content = obj.get("content")
|
content = obj.get("content")
|
||||||
encoding = obj.get("encoding")
|
encoding = obj.get("encoding")
|
||||||
|
|
||||||
# Base64 decode if possible
|
|
||||||
decoded = _maybe_decode_base64(content, encoding)
|
decoded = _maybe_decode_base64(content, encoding)
|
||||||
if decoded:
|
if decoded:
|
||||||
return decoded
|
return decoded
|
||||||
|
|
||||||
# content may already be plain text
|
|
||||||
if isinstance(content, str) and (not isinstance(encoding, str) or not encoding.strip()):
|
if isinstance(content, str) and (not isinstance(encoding, str) or not encoding.strip()):
|
||||||
# Heuristic: treat as markdown if it has typical markdown chars, otherwise still return
|
|
||||||
return content
|
return content
|
||||||
|
|
||||||
# 2) direct text keys (readme/markdown/text/body/data)
|
|
||||||
for k in _TEXT_KEYS:
|
for k in _TEXT_KEYS:
|
||||||
v = obj.get(k)
|
v = obj.get(k)
|
||||||
if isinstance(v, str):
|
if isinstance(v, str):
|
||||||
@@ -175,7 +157,6 @@ def _extract_text_recursive(obj: Any, depth: int = 0) -> str | None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# 3) Sometimes nested under "file" / "result" / "payload" etc.
|
|
||||||
for v in obj.values():
|
for v in obj.values():
|
||||||
out = _extract_text_recursive(v, depth + 1)
|
out = _extract_text_recursive(v, depth + 1)
|
||||||
if out:
|
if out:
|
||||||
@@ -198,7 +179,7 @@ class StaticAssetsView(HomeAssistantView):
|
|||||||
name = "api:bahmcloud_store_static"
|
name = "api:bahmcloud_store_static"
|
||||||
requires_auth = False
|
requires_auth = False
|
||||||
|
|
||||||
async def get(self, request: web.Request, path: str) -> web.Response:
|
async def get(self, request: web.Request, path: str) -> web.StreamResponse:
|
||||||
base = Path(__file__).resolve().parent / "panel"
|
base = Path(__file__).resolve().parent / "panel"
|
||||||
base_resolved = base.resolve()
|
base_resolved = base.resolve()
|
||||||
|
|
||||||
@@ -218,24 +199,7 @@ class StaticAssetsView(HomeAssistantView):
|
|||||||
_LOGGER.error("BCS static asset not found: %s", target)
|
_LOGGER.error("BCS static asset not found: %s", target)
|
||||||
return web.Response(status=404)
|
return web.Response(status=404)
|
||||||
|
|
||||||
content_type = "text/plain"
|
resp = web.FileResponse(path=target)
|
||||||
charset = None
|
|
||||||
|
|
||||||
if target.suffix == ".js":
|
|
||||||
content_type = "application/javascript"
|
|
||||||
charset = "utf-8"
|
|
||||||
elif target.suffix == ".html":
|
|
||||||
content_type = "text/html"
|
|
||||||
charset = "utf-8"
|
|
||||||
elif target.suffix == ".css":
|
|
||||||
content_type = "text/css"
|
|
||||||
charset = "utf-8"
|
|
||||||
elif target.suffix == ".svg":
|
|
||||||
content_type = "image/svg+xml"
|
|
||||||
elif target.suffix == ".png":
|
|
||||||
content_type = "image/png"
|
|
||||||
|
|
||||||
resp = web.Response(body=target.read_bytes(), content_type=content_type, charset=charset)
|
|
||||||
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
|
||||||
resp.headers["Pragma"] = "no-cache"
|
resp.headers["Pragma"] = "no-cache"
|
||||||
return resp
|
return resp
|
||||||
@@ -247,15 +211,34 @@ class BCSApiView(HomeAssistantView):
|
|||||||
requires_auth = True
|
requires_auth = True
|
||||||
|
|
||||||
def __init__(self, core: Any) -> None:
|
def __init__(self, core: Any) -> None:
|
||||||
self.core = core
|
self.core: BCSCore = core
|
||||||
|
|
||||||
async def get(self, request: web.Request) -> web.Response:
|
async def get(self, request: web.Request) -> web.Response:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"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:
|
async def post(self, request: web.Request) -> web.Response:
|
||||||
|
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()
|
data = await request.json()
|
||||||
|
except Exception:
|
||||||
|
data = {}
|
||||||
|
|
||||||
op = data.get("op")
|
op = data.get("op")
|
||||||
|
|
||||||
if op == "add_custom_repo":
|
if op == "add_custom_repo":
|
||||||
@@ -270,13 +253,44 @@ class BCSApiView(HomeAssistantView):
|
|||||||
return web.json_response({"ok": False, "message": "Unknown operation"}, status=400)
|
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):
|
class BCSCustomRepoView(HomeAssistantView):
|
||||||
url = "/api/bcs/custom_repo"
|
url = "/api/bcs/custom_repo"
|
||||||
name = "api:bcs_custom_repo"
|
name = "api:bcs_custom_repo"
|
||||||
requires_auth = True
|
requires_auth = True
|
||||||
|
|
||||||
def __init__(self, core: Any) -> None:
|
def __init__(self, core: Any) -> None:
|
||||||
self.core = core
|
self.core: BCSCore = core
|
||||||
|
|
||||||
async def delete(self, request: web.Request) -> web.Response:
|
async def delete(self, request: web.Request) -> web.Response:
|
||||||
repo_id = request.query.get("id")
|
repo_id = request.query.get("id")
|
||||||
@@ -292,7 +306,7 @@ class BCSReadmeView(HomeAssistantView):
|
|||||||
requires_auth = True
|
requires_auth = True
|
||||||
|
|
||||||
def __init__(self, core: Any) -> None:
|
def __init__(self, core: Any) -> None:
|
||||||
self.core = core
|
self.core: BCSCore = core
|
||||||
|
|
||||||
async def get(self, request: web.Request) -> web.Response:
|
async def get(self, request: web.Request) -> web.Response:
|
||||||
repo_id = request.query.get("repo_id")
|
repo_id = request.query.get("repo_id")
|
||||||
@@ -309,8 +323,210 @@ class BCSReadmeView(HomeAssistantView):
|
|||||||
status=404,
|
status=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ensure strict JSON string output (avoid accidental objects)
|
|
||||||
md_str = str(md)
|
md_str = str(md)
|
||||||
|
|
||||||
html = _render_markdown_server_side(md_str)
|
html = _render_markdown_server_side(md_str)
|
||||||
return web.json_response({"ok": True, "readme": md_str, "html": html})
|
return web.json_response({"ok": True, "readme": md_str, "html": html})
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user