129 Commits
0.1.0 ... 0.2.1

Author SHA1 Message Date
dc32010bf9 revert 692f0b47da
revert custom_components/bahmcloud_store/providers.py aktualisiert
2026-01-15 14:23:34 +00:00
3ed6a1a18c revert 106872063a
revert custom_components/bahmcloud_store/panel/panel.js aktualisiert
2026-01-15 14:22:11 +00:00
8ff5ab2e31 Dateien nach "custom_components/bahmcloud_store/panel" hochladen 2026-01-15 14:20:58 +00:00
2a0dc9d66c custom_components/bahmcloud_store/styles.css gelöscht 2026-01-15 14:20:03 +00:00
fbfc2e3a6e custom_components/bahmcloud_store/index.html gelöscht 2026-01-15 14:19:54 +00:00
19a5c0fecb custom_components/bahmcloud_store/app.js gelöscht 2026-01-15 14:19:48 +00:00
c0ec7b1797 custom_components/bahmcloud_store/panel.js gelöscht 2026-01-15 14:19:44 +00:00
25f966853a Dateien nach "custom_components/bahmcloud_store" hochladen 2026-01-15 14:09:05 +00:00
3edeab514b custom_components/bahmcloud_store/panel/styles.css gelöscht 2026-01-15 14:08:48 +00:00
d713bf779f custom_components/bahmcloud_store/panel/panel.js gelöscht 2026-01-15 14:08:44 +00:00
2b78feeadf custom_components/bahmcloud_store/panel/index.html gelöscht 2026-01-15 14:08:40 +00:00
a940c68e9e custom_components/bahmcloud_store/panel/app.js gelöscht 2026-01-15 14:08:35 +00:00
6c3cdcde61 Dateien nach "custom_components/bahmcloud_store" hochladen 2026-01-15 14:08:27 +00:00
1fc274bf7c Dateien nach "custom_components/bahmcloud_store" hochladen 2026-01-15 14:08:13 +00:00
4ff94bc185 custom_components/bahmcloud_store/views.py gelöscht 2026-01-15 14:07:49 +00:00
b95b3f5626 custom_components/bahmcloud_store/update.py gelöscht 2026-01-15 14:07:46 +00:00
30d47b775b custom_components/bahmcloud_store/store.py gelöscht 2026-01-15 14:07:42 +00:00
bedf6b6bf8 custom_components/bahmcloud_store/storage.py gelöscht 2026-01-15 14:07:38 +00:00
e2f8b4625a custom_components/bahmcloud_store/providers.py gelöscht 2026-01-15 14:07:34 +00:00
bb340108e2 custom_components/bahmcloud_store/manifest.json gelöscht 2026-01-15 14:07:31 +00:00
a617ca6709 custom_components/bahmcloud_store/metadata.py gelöscht 2026-01-15 14:07:27 +00:00
7789430d4a custom_components/bahmcloud_store/custom_repo_view.py gelöscht 2026-01-15 14:07:23 +00:00
4e8116265d custom_components/bahmcloud_store/core.py gelöscht 2026-01-15 14:07:19 +00:00
06796cf57b custom_components/bahmcloud_store/__init__.py gelöscht 2026-01-15 14:07:15 +00:00
dbcac9df86 custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 13:52:57 +00:00
6ca193580d custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 13:35:18 +00:00
3773b07650 custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 13:27:22 +00:00
24dcc92c00 custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 13:23:27 +00:00
596491f885 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 13:23:06 +00:00
97c9f01a0a custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 13:22:45 +00:00
c39a948c59 custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 13:09:32 +00:00
4fd0a6ec48 custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 13:05:40 +00:00
b84ab944b3 custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 13:05:24 +00:00
ce4bd4f4f1 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 13:05:07 +00:00
2dce858a51 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 12:55:47 +00:00
2c8da4a049 custom_components/bahmcloud_store/providers.py aktualisiert 2026-01-15 12:55:23 +00:00
692f0b47da custom_components/bahmcloud_store/providers.py aktualisiert 2026-01-15 12:54:13 +00:00
106872063a custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 12:45:18 +00:00
597d1556ff custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 12:34:44 +00:00
c4d9f7b393 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 12:27:56 +00:00
f15d932d54 Tes 2026-01-15 12:23:38 +00:00
ec60211339 Rrst 2026-01-15 12:17:58 +00:00
1305656d10 Test2 2026-01-15 12:12:23 +00:00
1c8a83effc Test 2026-01-15 12:05:24 +00:00
066d1ff2a4 Test 2026-01-15 12:04:50 +00:00
d1a8526d2d . 2026-01-15 11:57:43 +00:00
f60b3a8730 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 11:46:56 +00:00
b5e98898e0 custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 11:36:15 +00:00
236099e562 custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 11:24:44 +00:00
08a59ec56e custom_components/bahmcloud_store/metadata.py aktualisiert 2026-01-15 11:18:34 +00:00
5cf8e6d40f custom_components/bahmcloud_store/providers.py aktualisiert 2026-01-15 11:18:17 +00:00
8b21d070f3 custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 11:12:50 +00:00
7219f82e7f custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 11:03:12 +00:00
c91a4ecba2 custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 10:53:06 +00:00
2b0bfb4caa custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 10:52:40 +00:00
e10b23a44a custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 10:52:18 +00:00
6d273cc182 custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 10:42:25 +00:00
35839d9c65 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 10:39:15 +00:00
6f0f588b03 custom_components/bahmcloud_store/panel/panel.js hinzugefügt 2026-01-15 10:33:11 +00:00
5b56b59ae8 custom_components/bahmcloud_store/panel/panel,js gelöscht 2026-01-15 10:32:58 +00:00
8ab487f00a custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 10:22:24 +00:00
0ea9319ba4 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 10:22:07 +00:00
46508f1c34 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 10:17:01 +00:00
12f4aec1f7 custom_components/bahmcloud_store/panel/panel,js hinzugefügt 2026-01-15 10:14:00 +00:00
225442f549 custom_components/bahmcloud_store/panel/panel.js gelöscht 2026-01-15 10:13:49 +00:00
714ced5d2c custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 10:13:30 +00:00
64835b719f custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 10:13:13 +00:00
acb01b9768 custom_components/bahmcloud_store/providers.py aktualisiert 2026-01-15 10:12:52 +00:00
bf29faab04 custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 10:04:25 +00:00
adb117672c custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 09:56:53 +00:00
8cee9e5e4d custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 09:48:25 +00:00
77b4522e3c custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 09:46:41 +00:00
bae4d0b84f custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 09:46:16 +00:00
c022b90fb5 custom_components/bahmcloud_store/providers.py aktualisiert 2026-01-15 09:45:57 +00:00
97c2672119 custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 09:36:00 +00:00
47e1524aef custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 09:35:34 +00:00
0bc824fe4a custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 09:20:50 +00:00
c500234e1d custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 09:20:16 +00:00
d27782ea9c custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 09:19:58 +00:00
6088d0a935 custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 09:19:43 +00:00
fbdc8aed0f custom_components/bahmcloud_store/manifest.json aktualisiert 2026-01-15 09:19:21 +00:00
3723c403c7 CHANGELOG.md aktualisiert 2026-01-15 09:18:52 +00:00
95a7a0689b custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 08:34:01 +00:00
9d04aeaa58 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 08:33:33 +00:00
f65819ffab custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 08:33:16 +00:00
ad9c4ea421 custom_components/bahmcloud_store/providers.py aktualisiert 2026-01-15 08:32:58 +00:00
e0cecfcc68 custom_components/bahmcloud_store/metadata.py hinzugefügt 2026-01-15 08:32:39 +00:00
fbac0ac57f custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 08:32:22 +00:00
daa51cd59c custom_components/bahmcloud_store/manifest.json aktualisiert 2026-01-15 08:32:08 +00:00
3a88d2c402 CHANGELOG.md aktualisiert 2026-01-15 08:24:17 +00:00
9ff89d18f3 custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 08:12:10 +00:00
93ace71a12 custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 08:06:22 +00:00
30a4daa884 custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 07:59:28 +00:00
b40e509362 custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 07:52:18 +00:00
d04bf2a3f1 custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 07:47:37 +00:00
c490c7856c custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 07:47:09 +00:00
bd274faf88 custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 07:25:52 +00:00
638ac9a7ec custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 07:18:57 +00:00
3eb6d24439 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 07:18:29 +00:00
013b0baa83 custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 07:18:13 +00:00
b4b6b2b987 custom_components/bahmcloud_store/providers.py aktualisiert 2026-01-15 07:17:51 +00:00
d226edaac8 custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 07:17:22 +00:00
0e081e8cce custom_components/bahmcloud_store/manifest.json aktualisiert 2026-01-15 07:17:02 +00:00
bd50c487b1 CHANGELOG.md aktualisiert 2026-01-15 07:16:41 +00:00
58e62b864e custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 07:07:45 +00:00
41fc0da76c custom_components/bahmcloud_store/update.py aktualisiert 2026-01-15 07:07:17 +00:00
ce52920c6d custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 07:06:47 +00:00
5c47479f45 custom_components/bahmcloud_store/storage.py aktualisiert 2026-01-15 07:06:30 +00:00
80eefabbc2 custom_components/bahmcloud_store/core.py aktualisiert 2026-01-15 07:05:58 +00:00
0339ad4ecb custom_components/bahmcloud_store/providers.py hinzugefügt 2026-01-15 07:05:32 +00:00
3f07c09c36 custom_components/bahmcloud_store/__init__.py aktualisiert 2026-01-15 07:05:09 +00:00
dd634fca32 custom_components/bahmcloud_store/manifest.json aktualisiert 2026-01-15 07:04:53 +00:00
2aebc45707 CHANGELOG.md aktualisiert 2026-01-15 07:04:29 +00:00
4f3a7fb436 CHANGELOG.md aktualisiert 2026-01-15 07:01:03 +00:00
8e01de3440 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 06:45:04 +00:00
b7ed65b49d custom_components/bahmcloud_store/custom_repo_view.py aktualisiert 2026-01-15 06:15:58 +00:00
15349d93a2 custom_components/bahmcloud_store/views.py aktualisiert 2026-01-15 06:15:36 +00:00
124693e545 custom_components/bahmcloud_store/panel/panel.js aktualisiert 2026-01-15 06:12:34 +00:00
3aee3886b1 custom_components/bahmcloud_store/update.py aktualisiert 2026-01-15 06:12:19 +00:00
199bda2e0f custom_components/bahmcloud_store/custom_repo_view.py hinzugefügt 2026-01-15 06:11:34 +00:00
8d1ed31431 custom_components/bahmcloud_store/views.py hinzugefügt 2026-01-15 06:11:12 +00:00
c36321db43 custom_components/bahmcloud_store/storage.py hinzugefügt 2026-01-15 06:10:50 +00:00
806524ad33 custom_components/bahmcloud_store/core.py hinzugefügt 2026-01-15 06:10:29 +00:00
2da0cfe07d add v 0.2.0 2026-01-15 06:10:00 +00:00
603277d6f5 change to 0.2.0 2026-01-15 06:09:26 +00:00
a1bdf9dd40 add v 0.2.0 2026-01-15 06:08:31 +00:00
2746c5295a English 2026-01-15 05:45:12 +00:00
7bac73a37f Addend initial 2026-01-15 05:44:31 +00:00
96cdf234db Added chabgelog 2026-01-15 05:43:23 +00:00
12 changed files with 2344 additions and 120 deletions

95
CHANGELOG.md Normal file
View File

@@ -0,0 +1,95 @@
# Changelog
All notable changes to this repository will be documented in this file.
Sections:
- Added
- Changed
- Fixed
- Removed
- Security
---
## [0.4.0] - 2026-01-15
### Added
- Repository detail view (second page) in the Store UI.
- README rendering using Home Assistant's `ha-markdown` element.
- Floating action buttons (FAB):
- Open repository
- Reload README
- Install (coming soon)
- Update (coming soon)
- Search field and category filter on the repository list page.
- New authenticated API endpoint:
- `GET /api/bcs/readme?repo_id=<id>` returns README markdown (best-effort).
### Changed
- Repository cards are now clickable to open the detail view.
## [0.3.2] - 2026-01-15
### Added
- Metadata resolver:
- Reads `bcs.yaml` (preferred), then `hacs.yaml`, then `hacs.json` from repository root.
- Extracts `name`, `description`, `category`, `author`, `maintainer` (best-effort).
- UI now prefers metadata description over provider description.
- Provider repository name is now only used as a fallback if no metadata name is provided.
### Changed
- Repo display name priority:
1) metadata (`bcs.yaml` / `hacs.*`)
2) store index name (store.yaml)
3) provider repo name
4) repository URL
## [0.3.1] - 2026-01-15
### Fixed
- Panel header version is now derived from `manifest.json` via backend API (no more hardcoded version strings).
- Mobile navigation/header visibility improved by explicitly disabling iframe embedding for the custom panel.
- When adding a custom repository without a display name, the name is now fetched from the git provider (GitHub/Gitea) and shown automatically.
## [0.3.0] - 2026-01-15
### Added
- Repository enrichment for the Store UI:
- GitHub: fetch owner and description via GitHub REST API.
- Gitea: fetch owner and description via Gitea REST API (`/api/v1`).
- Provider detection for GitHub/GitLab/Gitea (best-effort).
- Automatic UI description line populated from provider data (when available).
### Changed
- Panel module URL cache-busting updated to avoid stale frontend assets.
### Fixed
- Store "Refresh" now triggers immediate backend refresh (from 0.2.0).
- Avoided circular imports by using TYPE_CHECKING for type references.
### Notes
- Installation/README details view/update entities will be added in later versions.
## [0.2.0] - 2026-01-15
### Added
- Foundation architecture for BCS (Bahmcloud Component Store) inside a Home Assistant custom component.
- Custom panel (no iframe) using `hass.callApi()` to avoid authentication issues.
- Store index loader (`store.yaml`) with periodic refresh (data only).
- Manual repository management:
- Add repository
- List repositories
- 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
- Store API/UI terminology standardized to "BCS" (Bahmcloud Component Store), while integration domain remains `bahmcloud_store` for compatibility.
### Notes
- Installation, README rendering, provider enrichment (GitHub/Gitea/GitLab), and Update entities will be implemented in later versions.

View File

@@ -1,3 +1,3 @@
# bahmcloud_store
Bahmcloud Store für installing costum_components to Homeassistant
Bahmcloud Store for installing costum_components to Homeassistant

View File

@@ -5,11 +5,11 @@ from datetime import timedelta
from homeassistant.core import HomeAssistant
from homeassistant.const import Platform
from homeassistant.helpers.event import async_track_time_interval
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 .store import BahmcloudStore, StoreConfig, StoreError
from .core import BCSCore, BCSConfig, BCSError
_LOGGER = logging.getLogger(__name__)
@@ -23,43 +23,37 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
cfg = config.get(DOMAIN, {})
store_url = cfg.get(CONF_STORE_URL, DEFAULT_STORE_URL)
store = BahmcloudStore(hass, StoreConfig(store_url=store_url))
hass.data[DOMAIN] = store
core = BCSCore(hass, BCSConfig(store_url=store_url))
hass.data[DOMAIN] = core
# HTTP Views (Panel static + JSON API)
await store.register_http_views()
await core.register_http_views()
# Sidebar Panel (Custom Panel + JS module)
# 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",
module_url="/api/bahmcloud_store_static/panel.js?v=42",
sidebar_title="Bahmcloud Store",
sidebar_icon="mdi:store",
require_admin=True,
config={},
)
# Initialer Index-Load
try:
await store.refresh()
except StoreError as e:
_LOGGER.error("Initial store refresh failed: %s", e)
await core.refresh()
except BCSError as e:
_LOGGER.error("Initial refresh failed: %s", e)
# Nur Liste + Latest-Versionen regelmäßig aktualisieren (keine Auto-Install)
async def periodic(_now) -> None:
try:
await store.refresh()
store.signal_entities_updated()
except StoreError as e:
await core.refresh()
core.signal_updated()
except BCSError as e:
_LOGGER.warning("Periodic refresh failed: %s", e)
# Falls store.yaml refresh_seconds enthält, nutze das, sonst 300s
interval_seconds = store.refresh_seconds if getattr(store, "refresh_seconds", None) else 300
async_track_time_interval(hass, periodic, timedelta(seconds=int(interval_seconds)))
interval = timedelta(seconds=int(core.refresh_seconds or 300))
async_track_time_interval(hass, periodic, interval)
# Update platform laden (damit Updates in Settings erscheinen)
await async_load_platform(hass, Platform.UPDATE, DOMAIN, {}, config)
return True

View File

@@ -0,0 +1,323 @@
from __future__ import annotations
import asyncio
import json
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util import yaml as ha_yaml
from .storage import BCSStorage, CustomRepo
from .views import StaticAssetsView, BCSApiView, BCSReadmeView
from .custom_repo_view import BCSCustomRepoView
from .providers import fetch_repo_info, detect_provider, RepoInfo
from .metadata import fetch_repo_metadata, RepoMetadata
_LOGGER = logging.getLogger(__name__)
DOMAIN = "bahmcloud_store"
class BCSError(Exception):
"""BCS core error."""
@dataclass
class BCSConfig:
store_url: str
@dataclass
class RepoItem:
id: str
name: str
url: str
source: str # "index" | "custom"
owner: str | None = None
provider: str | None = None
provider_repo_name: str | None = None
provider_description: str | None = None
default_branch: str | None = None
latest_version: str | None = None
latest_version_source: str | None = None # "release" | "tag" | None
meta_source: str | None = None
meta_name: str | None = None
meta_description: str | None = None
meta_category: str | None = None
meta_author: str | None = None
meta_maintainer: str | None = None
class BCSCore:
def __init__(self, hass: HomeAssistant, config: BCSConfig) -> None:
self.hass = hass
self.config = config
self.storage = BCSStorage(hass)
self.refresh_seconds: int = 300
self.repos: dict[str, RepoItem] = {}
self._listeners: list[callable] = []
self.version: str = self._read_manifest_version()
def _read_manifest_version(self) -> str:
try:
manifest_path = Path(__file__).resolve().parent / "manifest.json"
data = json.loads(manifest_path.read_text(encoding="utf-8"))
v = data.get("version")
return str(v) if v else "unknown"
except Exception:
return "unknown"
def add_listener(self, cb) -> None:
self._listeners.append(cb)
def signal_updated(self) -> None:
for cb in list(self._listeners):
try:
cb()
except Exception:
pass
async def register_http_views(self) -> None:
self.hass.http.register_view(StaticAssetsView())
self.hass.http.register_view(BCSApiView(self))
self.hass.http.register_view(BCSReadmeView(self))
self.hass.http.register_view(BCSCustomRepoView(self))
def get_repo(self, repo_id: str) -> RepoItem | None:
return self.repos.get(repo_id)
async def refresh(self) -> None:
index_repos, refresh_seconds = await self._load_index_repos()
self.refresh_seconds = refresh_seconds
custom_repos = await self.storage.list_custom_repos()
merged: dict[str, RepoItem] = {}
for item in index_repos:
merged[item.id] = item
for c in custom_repos:
merged[c.id] = RepoItem(
id=c.id,
name=(c.name or c.url),
url=c.url,
source="custom",
)
for r in merged.values():
r.provider = detect_provider(r.url)
await self._enrich_and_resolve(merged)
self.repos = merged
async def _enrich_and_resolve(self, merged: dict[str, RepoItem]) -> None:
sem = asyncio.Semaphore(6)
async def process_one(r: RepoItem) -> None:
async with sem:
info: RepoInfo = await fetch_repo_info(self.hass, r.url)
r.provider = info.provider or r.provider
r.owner = info.owner or r.owner
r.provider_repo_name = info.repo_name
r.provider_description = info.description
r.default_branch = info.default_branch or r.default_branch
r.latest_version = info.latest_version
r.latest_version_source = info.latest_version_source
md: RepoMetadata = await fetch_repo_metadata(self.hass, r.url, r.default_branch)
r.meta_source = md.source
r.meta_name = md.name
r.meta_description = md.description
r.meta_category = md.category
r.meta_author = md.author
r.meta_maintainer = md.maintainer
has_user_or_index_name = bool(r.name) and (r.name != r.url) and (not str(r.name).startswith("http"))
if r.meta_name:
r.name = r.meta_name
elif not has_user_or_index_name and r.provider_repo_name:
r.name = r.provider_repo_name
elif not r.name:
r.name = r.url
await asyncio.gather(*(process_one(r) for r in merged.values()), return_exceptions=True)
async def _load_index_repos(self) -> tuple[list[RepoItem], int]:
session = async_get_clientsession(self.hass)
try:
async with session.get(self.config.store_url, timeout=20) as resp:
if resp.status != 200:
raise BCSError(f"store_url returned {resp.status}")
raw = await resp.text()
except Exception as e:
raise BCSError(f"Failed fetching store index: {e}") from e
try:
data = ha_yaml.parse_yaml(raw)
if not isinstance(data, dict):
raise BCSError("store.yaml must be a mapping")
refresh_seconds = int(data.get("refresh_seconds", 300))
repos = data.get("repos", [])
if not isinstance(repos, list):
raise BCSError("store.yaml 'repos' must be a list")
items: list[RepoItem] = []
for i, r in enumerate(repos):
if not isinstance(r, dict):
continue
url = str(r.get("url", "")).strip()
if not url:
continue
name = str(r.get("name") or url).strip()
items.append(
RepoItem(
id=f"index:{i}",
name=name,
url=url,
source="index",
)
)
return items, refresh_seconds
except Exception as e:
raise BCSError(f"Invalid store.yaml: {e}") from e
async def add_custom_repo(self, url: str, name: str | None) -> CustomRepo:
repo = await self.storage.add_custom_repo(url=url, name=name)
await self.refresh()
self.signal_updated()
return repo
async def remove_custom_repo(self, repo_id: str) -> None:
await self.storage.remove_custom_repo(repo_id)
await self.refresh()
self.signal_updated()
async def list_custom_repos(self) -> list[CustomRepo]:
return await self.storage.list_custom_repos()
def list_repos_public(self) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for r in self.repos.values():
resolved_description = r.meta_description or r.provider_description
out.append(
{
"id": r.id,
"name": r.name,
"url": r.url,
"source": r.source,
"owner": r.owner,
"provider": r.provider,
"meta_source": r.meta_source,
"meta_name": r.meta_name,
"meta_description": r.meta_description,
"meta_category": r.meta_category,
"meta_author": r.meta_author,
"meta_maintainer": r.meta_maintainer,
"provider_repo_name": r.provider_repo_name,
"provider_description": r.provider_description,
"description": resolved_description,
"category": r.meta_category,
"latest_version": r.latest_version,
"latest_version_source": r.latest_version_source,
}
)
return out
# ----------------------------
# README fetching
# ----------------------------
def _normalize_repo_name(self, name: str | None) -> str | None:
if not name:
return None
n = name.strip()
if n.endswith(".git"):
n = n[:-4]
return n or None
def _split_owner_repo(self, repo_url: str) -> tuple[str | None, str | None]:
u = urlparse(repo_url.rstrip("/"))
parts = [p for p in u.path.strip("/").split("/") if p]
if len(parts) < 2:
return None, None
owner = parts[0].strip() or None
repo = self._normalize_repo_name(parts[1])
return owner, repo
def _is_github(self, repo_url: str) -> bool:
return "github.com" in urlparse(repo_url).netloc.lower()
def _is_gitea(self, repo_url: str) -> bool:
host = urlparse(repo_url).netloc.lower()
return host and "github.com" not in host and "gitlab.com" not in host
async def _fetch_text(self, url: str) -> str | None:
session = async_get_clientsession(self.hass)
try:
async with session.get(url, timeout=20) as resp:
if resp.status != 200:
return None
return await resp.text()
except Exception:
return None
async def fetch_readme_markdown(self, repo_id: str) -> str | None:
repo = self.get_repo(repo_id)
if not repo:
return None
owner, name = self._split_owner_repo(repo.url)
if not owner or not name:
return None
branch = repo.default_branch or "main"
filenames = ["README.md", "readme.md", "README.MD"]
candidates: list[str] = []
if self._is_github(repo.url):
# raw github content
base = f"https://raw.githubusercontent.com/{owner}/{name}/{branch}"
candidates.extend([f"{base}/{fn}" for fn in filenames])
elif self._is_gitea(repo.url):
u = urlparse(repo.url.rstrip("/"))
root = f"{u.scheme}://{u.netloc}/{owner}/{name}"
# gitea raw endpoints (both common forms)
bases = [
f"{root}/raw/branch/{branch}",
f"{root}/raw/{branch}",
]
for b in bases:
candidates.extend([f"{b}/{fn}" for fn in filenames])
else:
return None
for url in candidates:
txt = await self._fetch_text(url)
if txt:
return txt
return None

View File

@@ -0,0 +1,28 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.components.http import HomeAssistantView
if TYPE_CHECKING:
from .core import BCSCore
class BCSCustomRepoView(HomeAssistantView):
"""
DELETE /api/bcs/custom_repo?id=...
"""
requires_auth = True
name = "bcs_custom_repo_api"
url = "/api/bcs/custom_repo"
def __init__(self, core: "BCSCore") -> None:
self.core = core
async def delete(self, request):
repo_id = request.query.get("id", "").strip()
if not repo_id:
return self.json({"error": "id missing"}, status_code=400)
await self.core.remove_custom_repo(repo_id)
return self.json({"ok": True})

View File

@@ -1,8 +1,8 @@
{
"domain": "bahmcloud_store",
"name": "Bahmcloud Store",
"version": "0.1.0",
"documentation": "https://git.bahmcloud.de/bahmcloud/ha_store",
"version": "0.4.0",
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
"requirements": [],
"codeowners": [],
"iot_class": "local_polling"

View File

@@ -0,0 +1,168 @@
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from urllib.parse import urlparse
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util import yaml as ha_yaml
_LOGGER = logging.getLogger(__name__)
@dataclass
class RepoMetadata:
source: str | None = None # "bcs.yaml" | "hacs.yaml" | "hacs.json" | None
name: str | None = None
description: str | None = None
category: str | None = None
author: str | None = None
maintainer: str | None = None
def _normalize_repo_name(name: str | None) -> str | None:
if not name:
return None
n = name.strip()
if n.endswith(".git"):
n = n[:-4]
return n or None
def _split_owner_repo(repo_url: str) -> tuple[str | None, str | None]:
u = urlparse(repo_url.rstrip("/"))
parts = [p for p in u.path.strip("/").split("/") if p]
if len(parts) < 2:
return None, None
owner = parts[0].strip() or None
repo = _normalize_repo_name(parts[1])
return owner, repo
def _is_github(repo_url: str) -> bool:
return "github.com" in urlparse(repo_url).netloc.lower()
def _is_gitlab(repo_url: str) -> bool:
return "gitlab" in urlparse(repo_url).netloc.lower()
def _is_gitea(repo_url: str) -> bool:
host = urlparse(repo_url).netloc.lower()
return host and ("github.com" not in host) and ("gitlab" not in host)
async def _fetch_text(hass: HomeAssistant, url: str) -> str | None:
session = async_get_clientsession(hass)
try:
async with session.get(url, timeout=20) as resp:
if resp.status != 200:
return None
return await resp.text()
except Exception:
return None
def _parse_meta_yaml(raw: str, source: str) -> RepoMetadata:
try:
data = ha_yaml.parse_yaml(raw)
if not isinstance(data, dict):
return RepoMetadata(source=source)
return RepoMetadata(
source=source,
name=data.get("name"),
description=data.get("description"),
category=data.get("category"),
author=data.get("author"),
maintainer=data.get("maintainer"),
)
except Exception:
return RepoMetadata(source=source)
def _parse_meta_hacs_json(raw: str) -> RepoMetadata:
try:
data = json.loads(raw)
if not isinstance(data, dict):
return RepoMetadata(source="hacs.json")
name = data.get("name")
description = data.get("description")
author = data.get("author")
maintainer = data.get("maintainer")
category = data.get("category") or data.get("type")
return RepoMetadata(
source="hacs.json",
name=name if isinstance(name, str) else None,
description=description if isinstance(description, str) else None,
category=category if isinstance(category, str) else None,
author=author if isinstance(author, str) else None,
maintainer=maintainer if isinstance(maintainer, str) else None,
)
except Exception:
return RepoMetadata(source="hacs.json")
async def fetch_repo_metadata(hass: HomeAssistant, repo_url: str, default_branch: str | None) -> RepoMetadata:
owner, repo = _split_owner_repo(repo_url)
if not owner or not repo:
return RepoMetadata()
branch = default_branch or "main"
# Priority:
# 1) bcs.yaml
# 2) hacs.yaml
# 3) hacs.json
filenames = ["bcs.yaml", "hacs.yaml", "hacs.json"]
candidates: list[tuple[str, str]] = []
if _is_github(repo_url):
base = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}"
for fn in filenames:
candidates.append((fn, f"{base}/{fn}"))
elif _is_gitlab(repo_url):
u = urlparse(repo_url.rstrip("/"))
root = f"{u.scheme}://{u.netloc}/{owner}/{repo}"
# GitLab raw format
# https://gitlab.com/<owner>/<repo>/-/raw/<branch>/<file>
for fn in filenames:
candidates.append((fn, f"{root}/-/raw/{branch}/{fn}"))
elif _is_gitea(repo_url):
u = urlparse(repo_url.rstrip("/"))
root = f"{u.scheme}://{u.netloc}/{owner}/{repo}"
bases = [
f"{root}/raw/branch/{branch}",
f"{root}/raw/{branch}",
]
for fn in filenames:
for b in bases:
candidates.append((fn, f"{b}/{fn}"))
else:
return RepoMetadata()
for fn, url in candidates:
raw = await _fetch_text(hass, url)
if not raw:
continue
if fn.endswith(".json"):
meta = _parse_meta_hacs_json(raw)
if meta.source:
return meta
continue
meta = _parse_meta_yaml(raw, fn)
if meta.source:
return meta
return RepoMetadata()

View File

@@ -1,24 +1,945 @@
class BahmcloudStorePanel extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this._hass = null;
this._view = "store"; // store | manage | about | detail
this._data = null;
this._loading = true;
this._error = null;
this._customAddUrl = "";
this._customAddName = "";
this._search = "";
this._category = "all";
this._provider = "all"; // all|github|gitea|gitlab|other|custom
this._detailRepoId = null;
this._detailRepo = null;
this._readmeLoading = false;
this._readmeText = null;
this._readmeHtml = null;
this._readmeError = null;
}
set hass(hass) {
if (this._rendered) return;
this._rendered = true;
this._hass = hass;
if (!this._rendered) {
this._rendered = true;
this._render();
this._load();
}
}
const root = this.attachShadow({ mode: "open" });
const iframe = document.createElement("iframe");
async _load() {
if (!this._hass) return;
iframe.src = "/api/bahmcloud_store_static/index.html";
iframe.style.width = "100%";
iframe.style.height = "100%";
iframe.style.border = "0";
iframe.style.display = "block";
this._loading = true;
this._error = null;
this._update();
const style = document.createElement("style");
style.textContent = `
:host { display: block; height: 100vh; }
try {
const data = await this._hass.callApi("get", "bcs");
this._data = data;
} catch (e) {
this._error = e?.message ? String(e.message) : String(e);
} finally {
this._loading = false;
this._update();
}
}
_isDesktop() {
return window.matchMedia && window.matchMedia("(min-width: 1024px)").matches;
}
_toggleMenu() {
if (this._isDesktop()) return;
try {
const ev = new Event("hass-toggle-menu", { bubbles: true, composed: true });
this.dispatchEvent(ev);
} catch (_) {}
}
_goBack() {
if (this._view === "detail") {
this._view = "store";
this._detailRepoId = null;
this._detailRepo = null;
this._readmeText = null;
this._readmeHtml = null;
this._readmeError = null;
this._update();
return;
}
try {
history.back();
} catch (_) {
window.location.href = "/";
}
}
async _addCustomRepo() {
if (!this._hass) return;
const url = (this._customAddUrl || "").trim();
const name = (this._customAddName || "").trim() || null;
if (!url) {
this._error = "Please enter a repository URL.";
this._update();
return;
}
this._error = null;
this._update();
try {
await this._hass.callApi("post", "bcs", { op: "add_custom_repo", url, name });
this._customAddUrl = "";
this._customAddName = "";
await this._load();
this._view = "manage";
this._update();
} catch (e) {
this._error = e?.message ? String(e.message) : String(e);
this._update();
}
}
async _removeCustomRepo(id) {
if (!this._hass) return;
try {
await this._hass.callApi("delete", `bcs/custom_repo?id=${encodeURIComponent(id)}`);
await this._load();
this._view = "manage";
this._update();
} catch (e) {
this._error = e?.message ? String(e.message) : String(e);
this._update();
}
}
_openRepoDetail(repoId) {
const repos = Array.isArray(this._data?.repos) ? this._data.repos : [];
const repo = repos.find((r) => this._safeId(r?.id) === repoId);
if (!repo) return;
this._view = "detail";
this._detailRepoId = repoId;
this._detailRepo = repo;
this._readmeText = null;
this._readmeHtml = null;
this._readmeError = null;
this._update();
this._loadReadme(repoId);
}
async _loadReadme(repoId) {
if (!this._hass) return;
this._readmeLoading = true;
this._readmeError = null;
this._update();
try {
const resp = await this._hass.callApi(
"get",
`bcs/readme?repo_id=${encodeURIComponent(repoId)}`
);
if (resp?.ok && typeof resp.readme === "string" && resp.readme.trim()) {
this._readmeText = resp.readme;
this._readmeHtml = typeof resp.html === "string" && resp.html.trim() ? resp.html : null;
} else {
this._readmeText = null;
this._readmeHtml = null;
this._readmeError = this._safeText(resp?.message) || "README not found.";
}
} catch (e) {
this._readmeText = null;
this._readmeHtml = null;
this._readmeError = e?.message ? String(e.message) : String(e);
} finally {
this._readmeLoading = false;
this._update();
}
}
_render() {
const root = this.shadowRoot;
root.innerHTML = `
<style>
:host { display:block; min-height:100%; --bcs-accent:#1E88E5; }
.mobilebar{
position:sticky; top:0; z-index:50;
display:flex; align-items:center; justify-content:space-between;
gap:10px; padding:10px 12px;
background: var(--app-header-background-color, var(--card-background-color));
color: var(--app-header-text-color, var(--primary-text-color));
border-bottom:1px solid var(--divider-color);
}
.mobilebar .left, .mobilebar .right { display:flex; align-items:center; gap:10px; }
.iconbtn{
width:40px; height:40px; border-radius:14px;
border:1px solid var(--divider-color);
background: color-mix(in srgb, var(--card-background-color) 82%, transparent);
color:inherit; display:inline-flex; align-items:center; justify-content:center;
cursor:pointer; user-select:none; font-weight:900; font-size:18px; line-height:1;
}
.iconbtn:hover{ box-shadow: 0 10px 30px rgba(0,0,0,0.10); transform: translateY(-1px); }
.iconbtn:active{ transform: translateY(0px); box-shadow:none; }
@media (min-width: 1024px) { .iconbtn.menu { display:none; } }
.brandtitle{ display:flex; flex-direction:column; line-height:1.2; }
.brandtitle .t{ font-size:16px; font-weight:900; letter-spacing: .2px; }
.brandtitle .s{ font-size:12px; color:var(--secondary-text-color); margin-top:2px; }
.wrap{
padding:16px; max-width:1100px; margin:0 auto;
font-family: system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
color:var(--primary-text-color);
}
.tabs{ display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px; }
.tab{
border:1px solid var(--divider-color);
background:var(--card-background-color);
color:var(--primary-text-color);
padding:8px 12px; border-radius:999px;
cursor:pointer; font-weight:800; font-size:13px;
}
.tab.active{
border-color:var(--bcs-accent);
box-shadow:0 0 0 2px color-mix(in srgb, var(--bcs-accent) 20%, transparent);
}
button{
padding:10px 12px; border-radius:14px;
border:1px solid var(--divider-color);
background:var(--card-background-color);
color:var(--primary-text-color);
cursor:pointer; font-weight:900;
}
button.primary{
border-color:var(--bcs-accent);
background: color-mix(in srgb, var(--bcs-accent) 16%, var(--card-background-color));
}
button:hover{ box-shadow: 0 10px 30px rgba(0,0,0,0.10); transform: translateY(-1px); }
button:active{ transform: translateY(0px); box-shadow:none; }
button:disabled{ opacity: 0.55; cursor: not-allowed; }
.card{
border:1px solid var(--divider-color);
background:var(--card-background-color);
border-radius:18px; padding:12px; margin:10px 0;
}
.card.clickable{
cursor:pointer;
transition: transform 120ms ease, box-shadow 120ms ease;
}
.card.clickable:hover{
transform: translateY(-1px);
box-shadow: 0 12px 34px rgba(0,0,0,0.10);
}
.row{ display:flex; justify-content:space-between; gap:10px; align-items:flex-start; }
.muted{ color:var(--secondary-text-color); font-size:13px; margin-top:4px; }
.small{ font-size:12px; }
.badge{
border:1px solid var(--divider-color);
border-radius:999px; padding:2px 10px;
font-size:12px; font-weight:900; height:fit-content;
}
.badge.custom{ border-color:var(--bcs-accent); color:var(--bcs-accent); }
.error{ color:#b00020; white-space:pre-wrap; margin-top:10px; }
.grid2{ display:grid; grid-template-columns: 1fr; gap: 12px; }
@media (min-width: 900px){ .grid2{ grid-template-columns: 1.2fr 0.8fr; } }
.filters{ display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin-bottom:12px; }
.chips{ display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px; }
.chip{
display:inline-flex; align-items:center; gap:8px;
padding:6px 10px; border-radius:999px;
border:1px solid var(--divider-color);
background:var(--card-background-color);
cursor:pointer; user-select:none; font-weight:800; font-size:12px;
}
.chip strong{ font-size:12px; }
.chip.active{
border-color:var(--bcs-accent);
box-shadow:0 0 0 2px color-mix(in srgb, var(--bcs-accent) 18%, transparent);
}
input, select{
padding:10px 12px; border-radius:14px;
border:1px solid var(--divider-color);
background:var(--card-background-color);
color:var(--primary-text-color);
outline:none;
}
input:focus, select:focus{
border-color:var(--bcs-accent);
box-shadow:0 0 0 2px color-mix(in srgb, var(--bcs-accent) 20%, transparent);
}
a{ color:var(--bcs-accent); text-decoration:none; }
a:hover{ text-decoration:underline; }
.fabs{
position: fixed;
right: 18px;
bottom: 18px;
display: grid;
gap: 10px;
z-index: 100;
}
.fab{
width: 56px;
height: 56px;
border-radius: 18px;
border: 1px solid var(--divider-color);
background: var(--card-background-color);
box-shadow: 0 12px 30px rgba(0,0,0,0.14);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 900;
cursor: pointer;
user-select: none;
}
.fab.primary{
border-color: var(--bcs-accent);
background: color-mix(in srgb, var(--bcs-accent) 18%, var(--card-background-color));
}
.fab[disabled]{ opacity: .55; cursor: not-allowed; }
pre.readme{
white-space: pre-wrap;
word-break: break-word;
margin: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12.5px;
line-height: 1.5;
}
details{ margin-top: 10px; }
summary{ cursor:pointer; color: var(--bcs-accent); font-weight: 900; }
.md { line-height: 1.65; font-size: 14px; }
.md :is(h1,h2,h3){ margin: 18px 0 10px; }
.md :is(p,ul,ol,pre,blockquote,table){ margin: 10px 0; }
.md pre { overflow:auto; padding:12px; border-radius:14px; border:1px solid var(--divider-color); }
.md code { padding:2px 6px; border-radius:8px; border:1px solid var(--divider-color); }
.md blockquote { border-left:4px solid var(--bcs-accent); padding:8px 12px; border-radius:12px;
background: color-mix(in srgb, var(--bcs-accent) 8%, var(--card-background-color)); }
.md table{ width:100%; border-collapse: collapse; }
.md th,.md td{ border:1px solid var(--divider-color); padding:8px; text-align:left; }
.md img{ max-width:100%; height:auto; border-radius:12px; }
</style>
<div class="mobilebar">
<div class="left">
<div class="iconbtn menu" id="menuBtn" title="Menu">☰</div>
<div class="iconbtn" id="backBtn" title="Back">←</div>
<div class="brandtitle">
<div class="t">Bahmcloud Store</div>
<div class="s" id="subtitle">BCS — loading…</div>
</div>
</div>
<div class="right">
<button id="refreshBtn" class="primary">Refresh</button>
</div>
</div>
<div class="wrap">
<div class="tabs" id="tabs">
<div class="tab" data-view="store">Store</div>
<div class="tab" data-view="manage">Manage repositories</div>
<div class="tab" data-view="about">Settings / About</div>
</div>
<div id="content"></div>
<div id="error" class="error"></div>
</div>
<div id="fabs"></div>
`;
root.appendChild(style);
root.appendChild(iframe);
root.getElementById("refreshBtn").addEventListener("click", () => this._load());
root.getElementById("menuBtn").addEventListener("click", () => this._toggleMenu());
root.getElementById("backBtn").addEventListener("click", () => this._goBack());
for (const tab of root.querySelectorAll(".tab")) {
tab.addEventListener("click", () => {
this._view = tab.getAttribute("data-view");
this._update();
});
}
// prevent HA global shortcuts while typing
const stopIfFormField = (e) => {
const t = e.composedPath ? e.composedPath()[0] : e.target;
if (!t) return;
const tag = (t.tagName || "").toLowerCase();
const isEditable =
tag === "input" || tag === "textarea" || tag === "select" || t.isContentEditable;
if (isEditable) e.stopPropagation();
};
root.addEventListener("keydown", stopIfFormField, true);
root.addEventListener("keyup", stopIfFormField, true);
root.addEventListener("keypress", stopIfFormField, true);
}
_captureFocusState() {
const root = this.shadowRoot;
const ae = root.activeElement;
if (!ae || !ae.id) return null;
const supported = new Set(["searchInput", "categorySelect", "addUrl", "addName"]);
if (!supported.has(ae.id)) return null;
return {
id: ae.id,
value: ae.value,
selectionStart: typeof ae.selectionStart === "number" ? ae.selectionStart : null,
selectionEnd: typeof ae.selectionEnd === "number" ? ae.selectionEnd : null,
};
}
_restoreFocusState(state) {
if (!state) return;
const root = this.shadowRoot;
const el = root.getElementById(state.id);
if (!el) return;
try {
el.focus({ preventScroll: true });
if (
state.selectionStart !== null &&
state.selectionEnd !== null &&
typeof el.setSelectionRange === "function"
) {
el.setSelectionRange(state.selectionStart, state.selectionEnd);
}
} catch (_) {}
}
_update() {
const root = this.shadowRoot;
const focusState = this._captureFocusState();
const content = root.getElementById("content");
const err = root.getElementById("error");
const subtitle = root.getElementById("subtitle");
const fabs = root.getElementById("fabs");
const v = this._safeText(this._data?.version);
subtitle.textContent = v ? `BCS ${v}` : "BCS — loading…";
for (const tab of root.querySelectorAll(".tab")) {
tab.classList.toggle("active", tab.getAttribute("data-view") === this._view);
}
err.textContent = this._error ? `Error: ${this._error}` : "";
fabs.innerHTML = this._view === "detail" ? this._renderFabs() : "";
this._wireFabs();
if (this._loading) {
content.innerHTML = `<div class="card">Loading…</div>`;
this._restoreFocusState(focusState);
return;
}
if (!this._data) {
content.innerHTML = `<div class="card">No data.</div>`;
this._restoreFocusState(focusState);
return;
}
if (this._view === "store") {
content.innerHTML = this._renderStore();
this._wireStore();
this._restoreFocusState(focusState);
return;
}
if (this._view === "manage") {
content.innerHTML = this._renderManage();
this._wireManage();
this._restoreFocusState(focusState);
return;
}
if (this._view === "about") {
content.innerHTML = this._renderAbout();
this._restoreFocusState(focusState);
return;
}
content.innerHTML = this._renderDetail();
this._wireDetail();
this._restoreFocusState(focusState);
}
_renderStore() {
const repos = Array.isArray(this._data.repos) ? this._data.repos : [];
const categories = this._computeCategories(repos);
const stats = this._computeProviderStats(repos);
const filtered = repos
.filter((r) => {
const q = (this._search || "").trim().toLowerCase();
if (!q) return true;
const hay = `${this._safeText(r?.name)} ${this._safeText(r?.description)} ${this._safeText(r?.url)} ${this._safeText(r?.owner)}`.toLowerCase();
return hay.includes(q);
})
.filter((r) => {
if (this._category === "all") return true;
return this._safeLower(r?.category) === this._category;
})
.filter((r) => {
if (this._provider === "all") return true;
if (this._provider === "custom") return r?.source === "custom";
return this._safeLower(r?.provider) === this._provider;
});
const options = [
`<option value="all"${this._category === "all" ? " selected" : ""}>All categories</option>`,
...categories.map(
(c) =>
`<option value="${this._esc(c)}"${
this._category === c ? " selected" : ""
}>${this._esc(c)}</option>`
),
].join("");
const providerChips = this._renderProviderChips(stats);
const rows = filtered
.map((r) => {
const id = this._safeId(r?.id);
const name = this._safeText(r?.name) || "Unnamed repository";
const desc = this._safeText(r?.description) || "No description available.";
const badge =
r?.source === "custom"
? `<span class="badge custom">Custom</span>`
: `<span class="badge">Index</span>`;
const creator = this._safeText(r?.owner) ? `Creator: ${this._safeText(r?.owner)}` : "Creator: -";
const latest = this._safeText(r?.latest_version) ? `Latest: ${this._safeText(r?.latest_version)}` : "Latest: unknown";
const cat = this._safeText(r?.category) ? `Category: ${this._safeText(r?.category)}` : null;
const metaSrc = this._safeText(r?.meta_source) ? `Meta: ${this._safeText(r?.meta_source)}` : null;
const lineBits = [creator, latest, cat, metaSrc].filter(Boolean);
return `
<div class="card clickable" data-repo="${this._esc(id)}">
<div class="row">
<div>
<div><strong>${this._esc(name)}</strong></div>
<div class="muted">${this._esc(desc)}</div>
<div class="muted small">${this._esc(lineBits.join(" · "))}</div>
</div>
${badge}
</div>
</div>
`;
})
.join("");
return `
<div class="card">
<div><strong>Providers</strong></div>
<div class="chips" id="providerChips">${providerChips}</div>
</div>
<div class="filters">
<input id="searchInput" placeholder="Search repositories…" value="${this._esc(this._search)}" />
<select id="categorySelect">${options}</select>
</div>
${rows || `<div class="card">No repositories match this filter.</div>`}
`;
}
_wireStore() {
const root = this.shadowRoot;
const search = root.getElementById("searchInput");
const cat = root.getElementById("categorySelect");
const chips = root.getElementById("providerChips");
if (search) {
search.addEventListener("input", (e) => {
this._search = e.target.value;
this._update();
});
}
if (cat) {
cat.addEventListener("change", (e) => {
this._category = String(e.target.value || "all");
this._update();
});
}
if (chips) {
for (const c of chips.querySelectorAll("[data-prov]")) {
c.addEventListener("click", () => {
const key = c.getAttribute("data-prov");
if (!key) return;
this._provider = key;
this._update();
});
}
}
for (const card of root.querySelectorAll("[data-repo]")) {
card.addEventListener("click", () => {
const id = card.getAttribute("data-repo");
if (id) this._openRepoDetail(id);
});
}
}
_renderProviderChips(stats) {
const order = ["all", "github", "gitea", "gitlab", "other", "custom"];
const labels = {
all: "All",
github: "GitHub",
gitea: "Gitea",
gitlab: "GitLab",
other: "Other",
custom: "Custom",
};
const values = {
all: stats.total,
github: stats.github,
gitea: stats.gitea,
gitlab: stats.gitlab,
other: stats.other,
custom: stats.custom,
};
return order
.map((key) => {
const count = values[key] ?? 0;
const active = this._provider === key ? " active" : "";
return `<div class="chip${active}" data-prov="${key}"><strong>${labels[key]}</strong> ${count}</div>`;
})
.join("");
}
_computeProviderStats(repos) {
const s = { total: 0, github: 0, gitea: 0, gitlab: 0, other: 0, custom: 0 };
if (!Array.isArray(repos)) return s;
for (const r of repos) {
s.total += 1;
if (r?.source === "custom") s.custom += 1;
const p = this._safeLower(r?.provider) || "other";
if (p === "github") s.github += 1;
else if (p === "gitea") s.gitea += 1;
else if (p === "gitlab") s.gitlab += 1;
else s.other += 1;
}
return s;
}
_renderDetail() {
const r = this._detailRepo;
if (!r) return `<div class="card">Repository not found.</div>`;
const name = this._safeText(r?.name) || "Unnamed repository";
const desc = this._safeText(r?.description) || "No description available.";
const url = this._safeText(r?.url) || "#";
const badge =
r?.source === "custom"
? `<span class="badge custom">Custom</span>`
: `<span class="badge">Index</span>`;
const latest = this._safeText(r?.latest_version) ? `Latest: ${this._safeText(r?.latest_version)}` : "Latest: unknown";
const infoBits = [
this._safeText(r?.owner) ? `Creator: ${this._safeText(r?.owner)}` : "Creator: -",
latest,
this._safeText(r?.provider) ? `Provider: ${this._safeText(r?.provider)}` : null,
this._safeText(r?.category) ? `Category: ${this._safeText(r?.category)}` : null,
this._safeText(r?.meta_author) ? `Author: ${this._safeText(r?.meta_author)}` : null,
this._safeText(r?.meta_maintainer) ? `Maintainer: ${this._safeText(r?.meta_maintainer)}` : null,
this._safeText(r?.meta_source) ? `Meta: ${this._safeText(r?.meta_source)}` : null,
].filter(Boolean);
const readmeBlock = this._readmeLoading
? `<div class="card">Loading README…</div>`
: this._readmeText
? `
<div class="card">
<div class="row" style="align-items:center;">
<div><strong>README</strong></div>
<div class="muted small">Rendered Markdown</div>
</div>
<div id="readmePretty" class="md" style="margin-top:12px;"></div>
<details>
<summary>Show raw Markdown</summary>
<div style="margin-top:10px;">
<pre class="readme">${this._esc(this._readmeText)}</pre>
</div>
</details>
</div>
`
: `
<div class="card">
<div><strong>README</strong></div>
<div class="muted">${this._esc(this._readmeError || "README not found.")}</div>
</div>
`;
return `
<div class="grid2">
<div>
<div class="card">
<div class="row">
<div>
<div><strong style="font-size:16px;">${this._esc(name)}</strong></div>
<div class="muted">${this._esc(desc)}</div>
<div class="muted small" style="margin-top:8px;">${this._esc(infoBits.join(" · "))}</div>
<div class="muted small" style="margin-top:8px;">
<a href="${this._esc(url)}" target="_blank" rel="noreferrer">Open repository</a>
</div>
</div>
${badge}
</div>
</div>
${readmeBlock}
</div>
<div>
<div class="card">
<div><strong>Installation & Updates</strong></div>
<div class="muted">
Installation and updates are performed manually via <strong>Settings → System → Updates</strong>.
The Store UI is used to browse repositories and trigger installation/update actions.
</div>
<div class="muted small" style="margin-top:10px;">
Updates remain manual (like HACS).
</div>
</div>
</div>
</div>
`;
}
_wireDetail() {
const root = this.shadowRoot;
const mount = root.getElementById("readmePretty");
if (!mount) return;
if (this._readmeText) {
// Client renderer may be unavailable; prefer server-provided HTML
if (this._readmeHtml) {
mount.innerHTML = this._readmeHtml;
this._postprocessRenderedMarkdown(mount);
return;
}
mount.innerHTML = `<div class="muted">Rendered HTML not available. Use “Show raw Markdown”.</div>`;
return;
}
mount.innerHTML = "";
}
_postprocessRenderedMarkdown(container) {
if (!container) return;
try {
const links = container.querySelectorAll("a[href]");
links.forEach((a) => {
a.setAttribute("target", "_blank");
a.setAttribute("rel", "noreferrer noopener");
});
} catch (_) {}
}
_renderFabs() {
const r = this._detailRepo;
if (!r) return "";
return `
<div class="fabs">
<div class="fab primary" id="fabOpen" title="Open repository">↗</div>
<div class="fab" id="fabReload" title="Reload README">⟳</div>
<div class="fab" id="fabInstall" title="Install (coming soon)" disabled></div>
<div class="fab" id="fabUpdate" title="Update (coming soon)" disabled>↑</div>
<div class="fab" id="fabInfo" title="About">i</div>
</div>
`;
}
_wireFabs() {
const root = this.shadowRoot;
const r = this._detailRepo;
if (!r) return;
const url = this._safeText(r?.url);
const open = root.getElementById("fabOpen");
const reload = root.getElementById("fabReload");
const info = root.getElementById("fabInfo");
if (open) open.addEventListener("click", () => url && window.open(url, "_blank", "noreferrer"));
if (reload) {
reload.addEventListener("click", () => {
if (this._detailRepoId) this._loadReadme(this._detailRepoId);
});
}
if (info) {
info.addEventListener("click", () => {
this._view = "about";
this._update();
});
}
}
_renderManage() {
const repos = Array.isArray(this._data.repos) ? this._data.repos : [];
const custom = repos.filter((r) => r?.source === "custom");
const list = custom
.map((r) => {
const id = this._safeId(r?.id);
const name = this._safeText(r?.name) || "Unnamed repository";
const url = this._safeText(r?.url) || "";
return `
<div class="card">
<div class="row">
<div>
<div><strong>${this._esc(name)}</strong></div>
<div class="muted">${this._esc(url)}</div>
</div>
<div>
<button class="primary" data-remove="${this._esc(id)}">Remove</button>
</div>
</div>
</div>
`;
})
.join("");
return `
<div class="card">
<div><strong>Manage repositories</strong></div>
<div class="muted">Add public repositories from any git provider.</div>
<div style="display:grid; gap:10px; margin-top:12px;">
<div>
<div class="muted small">Repository URL</div>
<input id="addUrl" placeholder="https://github.com/user/repo" value="${this._esc(this._customAddUrl)}" />
</div>
<div>
<div class="muted small">Display name (optional)</div>
<input id="addName" placeholder="My Integration" value="${this._esc(this._customAddName)}" />
</div>
<div>
<button id="addBtn" class="primary">Add repository</button>
</div>
</div>
</div>
${list || `<div class="card">No custom repositories added yet.</div>`}
`;
}
_wireManage() {
const root = this.shadowRoot;
const addUrl = root.getElementById("addUrl");
const addName = root.getElementById("addName");
const addBtn = root.getElementById("addBtn");
if (addUrl) addUrl.addEventListener("input", (e) => (this._customAddUrl = e.target.value));
if (addName) addName.addEventListener("input", (e) => (this._customAddName = e.target.value));
if (addBtn) addBtn.addEventListener("click", () => this._addCustomRepo());
for (const btn of root.querySelectorAll("[data-remove]")) {
btn.addEventListener("click", () => {
const id = btn.getAttribute("data-remove");
if (id) this._removeCustomRepo(id);
});
}
}
_renderAbout() {
const v = this._safeText(this._data?.version) || "-";
return `
<div class="card">
<div><strong>Settings / About</strong></div>
<div class="muted">Language: English (v1). i18n will be added later.</div>
<div class="muted">Theme: follows Home Assistant light/dark automatically.</div>
<div class="muted">Accent: Bahmcloud Blue.</div>
<div class="muted small" style="margin-top: 10px;">BCS version: ${this._esc(v)}</div>
</div>
`;
}
_computeCategories(repos) {
const set = new Set();
for (const r of repos) {
const c = this._safeLower(r?.category);
if (c) set.add(c);
}
return Array.from(set).sort();
}
// --- HARDENING HELPERS (fixes [object Object]) ---
_safeText(v) {
if (v === null || v === undefined) return "";
const t = typeof v;
if (t === "string") return v;
if (t === "number" || t === "boolean") return String(v);
return ""; // objects/arrays/functions => empty (prevents [object Object])
}
_safeLower(v) {
const s = this._safeText(v);
return s ? s.trim().toLowerCase() : "";
}
_safeId(v) {
const s = this._safeText(v);
return s || "";
}
_esc(s) {
return String(s ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
}

View File

@@ -0,0 +1,378 @@
from __future__ import annotations
import logging
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from urllib.parse import quote_plus, urlparse
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
_LOGGER = logging.getLogger(__name__)
UA = "BahmcloudStore (Home Assistant)"
@dataclass
class RepoInfo:
owner: str | None = None
repo_name: str | None = None
description: str | None = None
provider: str | None = None
default_branch: str | None = None
latest_version: str | None = None
latest_version_source: str | None = None # "release" | "tag" | "atom" | None
def _normalize_repo_name(name: str | None) -> str | None:
if not name:
return None
n = name.strip()
if n.endswith(".git"):
n = n[:-4]
return n or None
def _split_owner_repo(repo_url: str) -> tuple[str | None, str | None]:
u = urlparse(repo_url.rstrip("/"))
parts = [p for p in u.path.strip("/").split("/") if p]
if len(parts) < 2:
return None, None
owner = parts[0].strip() or None
repo = _normalize_repo_name(parts[1])
return owner, repo
def detect_provider(repo_url: str) -> str:
host = urlparse(repo_url).netloc.lower()
if "github.com" in host:
return "github"
if "gitlab" in host:
return "gitlab"
owner, repo = _split_owner_repo(repo_url)
if owner and repo:
return "gitea"
return "generic"
async def _safe_json(session, url: str, *, headers: dict | None = None, timeout: int = 20):
try:
async with session.get(url, timeout=timeout, headers=headers) as resp:
status = resp.status
if status != 200:
return None, status
return await resp.json(), status
except Exception:
return None, None
async def _safe_text(session, url: str, *, headers: dict | None = None, timeout: int = 20):
try:
async with session.get(url, timeout=timeout, headers=headers) as resp:
status = resp.status
if status != 200:
return None, status
return await resp.text(), status
except Exception:
return None, None
def _extract_tag_from_github_url(url: str) -> str | None:
m = re.search(r"/releases/tag/([^/?#]+)", url)
if m:
return m.group(1)
m = re.search(r"/tag/([^/?#]+)", url)
if m:
return m.group(1)
return None
def _strip_html(s: str) -> str:
# minimal HTML entity cleanup for meta descriptions
out = (
s.replace("&amp;", "&")
.replace("&quot;", '"')
.replace("&#39;", "'")
.replace("&lt;", "<")
.replace("&gt;", ">")
)
return re.sub(r"\s+", " ", out).strip()
def _extract_meta(html: str, *, prop: str | None = None, name: str | None = None) -> str | None:
# Extract <meta property="og:description" content="...">
# or <meta name="description" content="...">
if prop:
# property="..." content="..."
m = re.search(
r'<meta[^>]+property=["\']' + re.escape(prop) + r'["\'][^>]+content=["\']([^"\']+)["\']',
html,
flags=re.IGNORECASE,
)
if m:
return _strip_html(m.group(1))
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:
m = re.search(
r'<meta[^>]+name=["\']' + re.escape(name) + r'["\'][^>]+content=["\']([^"\']+)["\']',
html,
flags=re.IGNORECASE,
)
if m:
return _strip_html(m.group(1))
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
async def _github_description_html(hass: HomeAssistant, owner: str, repo: str) -> str | None:
"""
GitHub API may be rate-limited; fetch public HTML and read meta description.
"""
session = async_get_clientsession(hass)
headers = {
"User-Agent": UA,
"Accept": "text/html,application/xhtml+xml",
}
html, status = await _safe_text(session, f"https://github.com/{owner}/{repo}", headers=headers)
if not html or status != 200:
return None
desc = _extract_meta(html, prop="og:description")
if desc:
return desc
desc = _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]:
session = async_get_clientsession(hass)
headers = {"User-Agent": UA, "Accept": "application/atom+xml,text/xml;q=0.9,*/*;q=0.8"}
xml_text, _ = await _safe_text(session, f"https://github.com/{owner}/{repo}/releases.atom", headers=headers)
if not xml_text:
return None, None
try:
root = ET.fromstring(xml_text)
except Exception:
return None, None
for entry in root.findall(".//{*}entry"):
for link in entry.findall(".//{*}link"):
href = link.attrib.get("href")
if not href:
continue
tag = _extract_tag_from_github_url(href)
if tag:
return tag, "atom"
return None, None
async def _github_latest_version_redirect(hass: HomeAssistant, owner: str, repo: str) -> tuple[str | None, str | None]:
session = async_get_clientsession(hass)
headers = {"User-Agent": UA}
url = f"https://github.com/{owner}/{repo}/releases/latest"
try:
async with session.head(url, allow_redirects=False, timeout=15, headers=headers) as resp:
if resp.status in (301, 302, 303, 307, 308):
loc = resp.headers.get("Location")
if loc:
tag = _extract_tag_from_github_url(loc)
if tag:
return tag, "release"
except Exception:
pass
return None, None
async def _github_latest_version_api(hass: HomeAssistant, owner: str, repo: str) -> tuple[str | None, str | None]:
session = async_get_clientsession(hass)
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)
if isinstance(data, dict):
tag = data.get("tag_name") or data.get("name")
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)
if isinstance(data, list) and data:
tag = data[0].get("name")
if isinstance(tag, str) and tag.strip():
return tag.strip(), "tag"
return None, 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)
if tag:
return tag, src
return await _github_latest_version_api(hass, owner, repo)
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:
tag = data[0].get("tag_name") or data[0].get("name")
if isinstance(tag, str) and tag.strip():
return tag.strip(), "release"
data, _ = await _safe_json(session, f"{base}/api/v1/repos/{owner}/{repo}/tags?limit=1")
if isinstance(data, list) and data:
tag = data[0].get("name")
if isinstance(tag, str) and tag.strip():
return tag.strip(), "tag"
return None, None
async def _gitlab_latest_version(hass: HomeAssistant, base: str, owner: str, repo: str) -> tuple[str | None, str | None]:
session = async_get_clientsession(hass)
headers = {"User-Agent": UA}
project = quote_plus(f"{owner}/{repo}")
data, _ = await _safe_json(
session,
f"{base}/api/v4/projects/{project}/releases?per_page=1&order_by=released_at&sort=desc",
headers=headers,
)
if isinstance(data, list) and data:
tag = data[0].get("tag_name") or data[0].get("name")
if isinstance(tag, str) and tag.strip():
return tag.strip(), "release"
data, _ = await _safe_json(
session,
f"{base}/api/v4/projects/{project}/repository/tags?per_page=1&order_by=updated&sort=desc",
headers=headers,
)
if isinstance(data, list) and data:
tag = data[0].get("name")
if isinstance(tag, str) and tag.strip():
return tag.strip(), "tag"
return None, None
async def fetch_repo_info(hass: HomeAssistant, repo_url: str) -> RepoInfo:
provider = detect_provider(repo_url)
owner, repo = _split_owner_repo(repo_url)
info = RepoInfo(
owner=owner,
repo_name=repo,
description=None,
provider=provider,
default_branch=None,
latest_version=None,
latest_version_source=None,
)
if not owner or not repo:
return info
session = async_get_clientsession(hass)
try:
if provider == "github":
# Try API repo details (may be rate-limited)
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)
if isinstance(data, dict):
info.description = data.get("description")
info.repo_name = _normalize_repo_name(data.get("name")) or repo
info.default_branch = data.get("default_branch") or "main"
if isinstance(data.get("owner"), dict) and data["owner"].get("login"):
info.owner = data["owner"]["login"]
else:
# If API blocked, still set reasonable defaults
if status == 403:
_LOGGER.debug("GitHub API blocked/rate-limited for repo info %s/%s", owner, repo)
info.default_branch = "main"
# If description missing, fetch from GitHub HTML
if not info.description:
desc = await _github_description_html(hass, owner, repo)
if desc:
info.description = desc
ver, src = await _github_latest_version(hass, owner, repo)
info.latest_version = ver
info.latest_version_source = src
return info
if provider == "gitlab":
u = urlparse(repo_url.rstrip("/"))
base = f"{u.scheme}://{u.netloc}"
headers = {"User-Agent": UA}
project = quote_plus(f"{owner}/{repo}")
data, _ = await _safe_json(session, f"{base}/api/v4/projects/{project}", headers=headers)
if isinstance(data, dict):
info.description = data.get("description")
info.repo_name = _normalize_repo_name(data.get("path")) or repo
info.default_branch = data.get("default_branch") or "main"
ns = data.get("namespace")
if isinstance(ns, dict) and ns.get("path"):
info.owner = ns.get("path")
ver, src = await _gitlab_latest_version(hass, base, owner, repo)
info.latest_version = ver
info.latest_version_source = src
return info
if provider == "gitea":
u = urlparse(repo_url.rstrip("/"))
base = f"{u.scheme}://{u.netloc}"
data, _ = await _safe_json(session, f"{base}/api/v1/repos/{owner}/{repo}")
if isinstance(data, dict):
info.description = data.get("description")
info.repo_name = _normalize_repo_name(data.get("name")) or repo
info.default_branch = data.get("default_branch") or "main"
if isinstance(data.get("owner"), dict) and data["owner"].get("login"):
info.owner = data["owner"]["login"]
ver, src = await _gitea_latest_version(hass, base, owner, repo)
info.latest_version = ver
info.latest_version_source = src
return info
return info
except Exception as e:
_LOGGER.debug("Provider fetch failed for %s: %s", repo_url, e)
return info

View File

@@ -0,0 +1,78 @@
from __future__ import annotations
import uuid
from dataclasses import dataclass
from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
_STORAGE_VERSION = 1
_STORAGE_KEY = "bcs_store"
@dataclass
class CustomRepo:
id: str
url: str
name: str | None = None
class BCSStorage:
"""Persistent storage for manually added repositories."""
def __init__(self, hass: HomeAssistant) -> None:
self.hass = hass
self._store = 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["custom_repos"] = []
return data
async def _save(self, data: dict[str, Any]) -> None:
await self._store.async_save(data)
async def list_custom_repos(self) -> list[CustomRepo]:
data = await self._load()
repos = data.get("custom_repos", [])
out: list[CustomRepo] = []
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))
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
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)
return CustomRepo(id=str(r["id"]), url=str(r["url"]), name=r.get("name"))
rid = f"custom:{uuid.uuid4().hex[:10]}"
entry = {"id": rid, "url": url.strip(), "name": name.strip() if name else None}
repos.append(entry)
data["custom_repos"] = repos
await self._save(data)
return CustomRepo(id=rid, url=entry["url"], name=entry["name"])
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)]
await self._save(data)

View File

@@ -1,12 +1,12 @@
from __future__ import annotations
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
# 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.
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import DOMAIN
from .store import BahmcloudStore, Package
async def async_setup_platform(
hass: HomeAssistant,
@@ -14,81 +14,4 @@ async def async_setup_platform(
async_add_entities: AddEntitiesCallback,
discovery_info=None,
):
store: BahmcloudStore = hass.data[DOMAIN]
entities: dict[str, BahmcloudPackageUpdate] = {}
def should_have_update_entity(pkg: Package) -> bool:
# Store selbst immer als Update
if pkg.type == "store":
return True
# Andere Pakete nur, wenn installiert
return store.is_installed(pkg.domain)
def rebuild_entities() -> None:
# Create entities for packages that qualify
for pkg in store.packages.values():
if not should_have_update_entity(pkg):
continue
uid = f"{DOMAIN}:{pkg.id}"
if uid not in entities:
ent = BahmcloudPackageUpdate(store, pkg.id)
entities[uid] = ent
async_add_entities([ent], update_before_add=True)
# Refresh states
for ent in entities.values():
ent.async_write_ha_state()
store.add_listener(rebuild_entities)
rebuild_entities()
class BahmcloudPackageUpdate(UpdateEntity):
_attr_supported_features = UpdateEntityFeature.INSTALL
def __init__(self, store: BahmcloudStore, package_id: str) -> None:
self.store = store
self.package_id = package_id
self._attr_unique_id = f"{DOMAIN}_{package_id}"
self._attr_name = f"{package_id} update"
@property
def _pkg(self) -> Package | None:
return self.store.packages.get(self.package_id)
@property
def title(self) -> str | None:
pkg = self._pkg
return pkg.name if pkg else None
@property
def installed_version(self) -> str | None:
pkg = self._pkg
if not pkg:
return None
if not self.store.is_installed(pkg.domain):
return None
return self.store.installed_version(pkg.domain)
@property
def latest_version(self) -> str | None:
pkg = self._pkg
return pkg.latest_version if pkg else None
@property
def release_summary(self) -> str | None:
pkg = self._pkg
if not pkg:
return None
if pkg.release_url:
return f"Release: {pkg.release_url}"
return f"Repo: {pkg.repo}"
async def async_install(self, version: str | None, backup: bool, **kwargs) -> None:
pkg = self._pkg
if not pkg:
return
await self.store.install_from_zip(pkg)
self.async_write_ha_state()
return

View File

@@ -0,0 +1,316 @@
from __future__ import annotations
import base64
import logging
from dataclasses import asdict
from pathlib import Path
from typing import Any, TYPE_CHECKING
from aiohttp import web
from homeassistant.components.http import HomeAssistantView
if TYPE_CHECKING:
from .core import BCSCore # typing only
_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
html = mdlib.markdown(
text,
extensions=["fenced_code", "tables", "sane_lists", "toc"],
output_format="html5",
)
except Exception as e:
_LOGGER.debug("python-markdown render failed: %s", e)
html = None
if not html:
return None
# 2) Sanitize via bleach
try:
import bleach # type: ignore
allowed_tags = [
"p",
"br",
"hr",
"div",
"span",
"blockquote",
"pre",
"code",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"ul",
"ol",
"li",
"strong",
"em",
"b",
"i",
"u",
"s",
"a",
"img",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
]
allowed_attrs = {
"a": ["href", "title", "target", "rel"],
"img": ["src", "alt", "title"],
"th": ["align"],
"td": ["align"],
"*": ["class"],
}
sanitized = bleach.clean(
html,
tags=allowed_tags,
attributes=allowed_attrs,
protocols=["http", "https", "mailto"],
strip=True,
)
sanitized = sanitized.replace(
'<a href="',
'<a rel="noreferrer noopener" target="_blank" href="',
)
return sanitized
except Exception as e:
_LOGGER.debug("bleach sanitize failed/unavailable: %s", e)
return html
_TEXT_KEYS = ("readme", "markdown", "text", "content", "data", "body")
def _maybe_decode_base64(content: str, encoding: Any) -> str | None:
if not isinstance(content, str):
return None
enc = ""
if isinstance(encoding, str):
enc = encoding.strip().lower()
if "base64" not in enc:
return None
try:
raw = base64.b64decode(content.encode("utf-8"), validate=False)
return raw.decode("utf-8", errors="replace")
except Exception:
return 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
if isinstance(obj, bytes):
try:
return obj.decode("utf-8", errors="replace")
except Exception:
return None
if isinstance(obj, str):
return obj
if depth > 8:
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):
return v
if isinstance(v, bytes):
try:
return v.decode("utf-8", errors="replace")
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:
return out
return None
if isinstance(obj, list):
for item in obj:
out = _extract_text_recursive(item, depth + 1)
if out:
return out
return None
return None
class StaticAssetsView(HomeAssistantView):
url = "/api/bahmcloud_store_static/{path:.*}"
name = "api:bahmcloud_store_static"
requires_auth = False
async def get(self, request: web.Request, path: str) -> web.Response:
base = Path(__file__).resolve().parent / "panel"
base_resolved = base.resolve()
req_path = (path or "").lstrip("/")
if req_path == "":
req_path = "index.html"
target = (base / req_path).resolve()
if not str(target).startswith(str(base_resolved)):
return web.Response(status=404)
if target.is_dir():
target = (target / "index.html").resolve()
if not target.exists():
_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.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
resp.headers["Pragma"] = "no-cache"
return resp
class BCSApiView(HomeAssistantView):
url = "/api/bcs"
name = "api:bcs"
requires_auth = True
def __init__(self, core: Any) -> None:
self.core = 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()}
)
async def post(self, request: web.Request) -> web.Response:
data = await request.json()
op = data.get("op")
if op == "add_custom_repo":
url = str(data.get("url") or "").strip()
name = data.get("name")
name = str(name).strip() if name else None
if not url:
return web.json_response({"ok": False, "message": "Missing url"}, status=400)
repo = await self.core.add_custom_repo(url=url, name=name)
return web.json_response({"ok": True, "repo": asdict(repo)})
return web.json_response({"ok": False, "message": "Unknown operation"}, status=400)
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
async def delete(self, request: web.Request) -> web.Response:
repo_id = request.query.get("id")
if not repo_id:
return web.json_response({"ok": False, "message": "Missing id"}, status=400)
await self.core.remove_custom_repo(repo_id)
return web.json_response({"ok": True})
class BCSReadmeView(HomeAssistantView):
url = "/api/bcs/readme"
name = "api:bcs_readme"
requires_auth = True
def __init__(self, core: Any) -> None:
self.core = 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)
maybe_md = await self.core.fetch_readme_markdown(repo_id)
md = _extract_text_recursive(maybe_md)
if not md or not md.strip():
t = type(maybe_md).__name__
return web.json_response(
{"ok": False, "message": f"README not found or unsupported format (got {t})."},
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})