Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7ed65b49d | |||
| 15349d93a2 | |||
| 124693e545 | |||
| 3aee3886b1 | |||
| 199bda2e0f | |||
| 8d1ed31431 | |||
| c36321db43 | |||
| 806524ad33 | |||
| 2da0cfe07d | |||
| 603277d6f5 | |||
| a1bdf9dd40 | |||
| 2746c5295a | |||
| 7bac73a37f | |||
| 96cdf234db | |||
| 184b8f9a1d | |||
| 936b5d9e7f | |||
| 04ba73a29c | |||
| 5ded45c3f8 | |||
| 7b748a0f0b |
35
CHANGELOG.md
Normal file
35
CHANGELOG.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this repository will be documented in this file.
|
||||||
|
|
||||||
|
Sections:
|
||||||
|
- Added
|
||||||
|
- Changed
|
||||||
|
- Fixed
|
||||||
|
- Removed
|
||||||
|
- Security
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [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.
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
# bahmcloud_store
|
# bahmcloud_store
|
||||||
|
|
||||||
Bahmcloud Store für installing costum_components to Homeassistant
|
Bahmcloud Store for installing costum_components to Homeassistant
|
||||||
@@ -5,11 +5,11 @@ from datetime import timedelta
|
|||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.const import Platform
|
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.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 .store import BahmcloudStore, StoreConfig, StoreError
|
from .core import BCSCore, BCSConfig, BCSError
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -20,46 +20,48 @@ CONF_STORE_URL = "store_url"
|
|||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
async def async_setup(hass: HomeAssistant, config: dict) -> bool:
|
||||||
|
# YAML-based config for now:
|
||||||
|
# bahmcloud_store:
|
||||||
|
# store_url: "https://.../store.yaml"
|
||||||
cfg = config.get(DOMAIN, {})
|
cfg = config.get(DOMAIN, {})
|
||||||
store_url = cfg.get(CONF_STORE_URL, DEFAULT_STORE_URL)
|
store_url = cfg.get(CONF_STORE_URL, DEFAULT_STORE_URL)
|
||||||
|
|
||||||
store = BahmcloudStore(hass, StoreConfig(store_url=store_url))
|
core = BCSCore(hass, BCSConfig(store_url=store_url))
|
||||||
hass.data[DOMAIN] = store
|
hass.data[DOMAIN] = core
|
||||||
|
|
||||||
# HTTP Views (Panel static + JSON API)
|
# Register HTTP views (static panel assets + API)
|
||||||
await store.register_http_views()
|
await core.register_http_views()
|
||||||
|
|
||||||
# Sidebar Panel (Custom Panel + JS module)
|
# Register custom panel (no iframe; uses hass.callApi)
|
||||||
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",
|
module_url="/api/bahmcloud_store_static/panel.js?v=1",
|
||||||
sidebar_title="Bahmcloud Store",
|
sidebar_title="Bahmcloud Store",
|
||||||
sidebar_icon="mdi:store",
|
sidebar_icon="mdi:store",
|
||||||
require_admin=True,
|
require_admin=True,
|
||||||
config={},
|
config={},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialer Index-Load
|
# Initial refresh (index + custom repos)
|
||||||
try:
|
try:
|
||||||
await store.refresh()
|
await core.refresh()
|
||||||
except StoreError as e:
|
except BCSError as e:
|
||||||
_LOGGER.error("Initial store refresh failed: %s", e)
|
_LOGGER.error("Initial refresh failed: %s", e)
|
||||||
|
|
||||||
# Nur Liste + Latest-Versionen regelmäßig aktualisieren (keine Auto-Install)
|
# Periodic refresh (data only; no auto installs)
|
||||||
async def periodic(_now) -> None:
|
async def periodic(_now) -> None:
|
||||||
try:
|
try:
|
||||||
await store.refresh()
|
await core.refresh()
|
||||||
store.signal_entities_updated()
|
core.signal_updated()
|
||||||
except StoreError as e:
|
except BCSError as e:
|
||||||
_LOGGER.warning("Periodic refresh failed: %s", e)
|
_LOGGER.warning("Periodic refresh failed: %s", e)
|
||||||
|
|
||||||
# Falls store.yaml refresh_seconds enthält, nutze das, sonst 300s
|
interval = timedelta(seconds=int(core.refresh_seconds or 300))
|
||||||
interval_seconds = store.refresh_seconds if getattr(store, "refresh_seconds", None) else 300
|
async_track_time_interval(hass, periodic, interval)
|
||||||
async_track_time_interval(hass, periodic, timedelta(seconds=int(interval_seconds)))
|
|
||||||
|
|
||||||
# Update platform laden (damit Updates in Settings erscheinen)
|
# Update platform is a stub in 0.2.0 (keeps integration stable)
|
||||||
await async_load_platform(hass, Platform.UPDATE, DOMAIN, {}, config)
|
await async_load_platform(hass, Platform.UPDATE, DOMAIN, {}, config)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|||||||
197
custom_components/bahmcloud_store/core.py
Normal file
197
custom_components/bahmcloud_store/core.py
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
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
|
||||||
|
from .custom_repo_view import BCSCustomRepoView
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DOMAIN = "bahmcloud_store"
|
||||||
|
|
||||||
|
|
||||||
|
class BCSError(Exception):
|
||||||
|
"""BCS core error."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BCSConfig:
|
||||||
|
store_url: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RepoItem:
|
||||||
|
"""Merged repository item (from store.yaml + custom repos)."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
url: str
|
||||||
|
source: str # "index" | "custom"
|
||||||
|
|
||||||
|
# Enrichment fields (filled later)
|
||||||
|
owner: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
provider: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BCSCore:
|
||||||
|
"""Core services for BCS: indexing + storage + HTTP views."""
|
||||||
|
|
||||||
|
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] = []
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""Register static assets and API routes."""
|
||||||
|
self.hass.http.register_view(StaticAssetsView())
|
||||||
|
self.hass.http.register_view(BCSApiView(self))
|
||||||
|
self.hass.http.register_view(BCSCustomRepoView(self))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _provider_name(repo_url: str) -> str:
|
||||||
|
"""Best-effort provider detection by hostname."""
|
||||||
|
host = urlparse(repo_url).netloc.lower()
|
||||||
|
if "github.com" in host:
|
||||||
|
return "github"
|
||||||
|
if "gitlab.com" in host:
|
||||||
|
return "gitlab"
|
||||||
|
# Likely self-hosted (Gitea/GitLab/other)
|
||||||
|
return "generic"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _owner_from_url(repo_url: str) -> str | None:
|
||||||
|
"""Extract owner/group from common URL patterns."""
|
||||||
|
u = urlparse(repo_url.rstrip("/"))
|
||||||
|
parts = u.path.strip("/").split("/")
|
||||||
|
if len(parts) >= 2:
|
||||||
|
return parts[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def refresh(self) -> None:
|
||||||
|
"""Refresh merged repo list (index + custom)."""
|
||||||
|
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] = {}
|
||||||
|
|
||||||
|
# Index repos
|
||||||
|
for item in index_repos:
|
||||||
|
merged[item.id] = item
|
||||||
|
|
||||||
|
# Custom repos override by id (or add)
|
||||||
|
for c in custom_repos:
|
||||||
|
merged[c.id] = RepoItem(
|
||||||
|
id=c.id,
|
||||||
|
name=c.name or c.url,
|
||||||
|
url=c.url,
|
||||||
|
source="custom",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Enrich basic data (owner/provider); description will come later via provider APIs
|
||||||
|
for r in merged.values():
|
||||||
|
r.provider = self._provider_name(r.url)
|
||||||
|
r.owner = self._owner_from_url(r.url)
|
||||||
|
|
||||||
|
self.repos = merged
|
||||||
|
|
||||||
|
async def _load_index_repos(self) -> tuple[list[RepoItem], int]:
|
||||||
|
"""Load store.yaml and return (repos, refresh_seconds)."""
|
||||||
|
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()
|
||||||
|
|
||||||
|
rid = f"index:{i}"
|
||||||
|
items.append(
|
||||||
|
RepoItem(
|
||||||
|
id=rid,
|
||||||
|
name=name,
|
||||||
|
url=url,
|
||||||
|
source="index",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return items, refresh_seconds
|
||||||
|
except Exception as e:
|
||||||
|
raise BCSError(f"Invalid store.yaml: {e}") from e
|
||||||
|
|
||||||
|
# --- Custom repo management (used by API) ---
|
||||||
|
|
||||||
|
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]]:
|
||||||
|
"""Return repo list for UI."""
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for r in self.repos.values():
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"name": r.name,
|
||||||
|
"url": r.url,
|
||||||
|
"source": r.source,
|
||||||
|
"owner": r.owner,
|
||||||
|
"provider": r.provider,
|
||||||
|
"description": r.description,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
28
custom_components/bahmcloud_store/custom_repo_view.py
Normal file
28
custom_components/bahmcloud_store/custom_repo_view.py
Normal 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})
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"domain": "bahmcloud_store",
|
"domain": "bahmcloud_store",
|
||||||
"name": "Bahmcloud Store",
|
"name": "Bahmcloud Store",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"documentation": "https://git.bahmcloud.de/bahmcloud/ha_store",
|
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"codeowners": [],
|
"codeowners": ["@bahmcloud"],
|
||||||
"iot_class": "local_polling"
|
"iot_class": "local_polling"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,86 @@
|
|||||||
async function load() {
|
async function apiGet() {
|
||||||
const r = await fetch("/api/bahmcloud_store", { credentials: "same-origin" });
|
const r = await fetch("/api/bahmcloud_store", { credentials: "same-origin" });
|
||||||
const data = await r.json();
|
return await r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiPost(payload) {
|
||||||
|
const r = await fetch("/api/bahmcloud_store", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "same-origin",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
return await r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function el(tag, attrs = {}, children = []) {
|
||||||
|
const n = document.createElement(tag);
|
||||||
|
for (const [k, v] of Object.entries(attrs)) {
|
||||||
|
if (k === "class") n.className = v;
|
||||||
|
else if (k === "onclick") n.onclick = v;
|
||||||
|
else n.setAttribute(k, v);
|
||||||
|
}
|
||||||
|
for (const c of children) {
|
||||||
|
n.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function card(pkg) {
|
||||||
|
const installedBadge = el("span", { class: "badge" }, [pkg.installed ? "Installed" : "Not installed"]);
|
||||||
|
|
||||||
|
const title = el("div", {}, [
|
||||||
|
el("strong", {}, [pkg.name]),
|
||||||
|
el("div", { class: "muted" }, [pkg.repo]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ver = el("div", { class: "muted" }, [
|
||||||
|
`Installed: ${pkg.installed_version || "-"} | Latest: ${pkg.latest_version || "-"}`
|
||||||
|
]);
|
||||||
|
|
||||||
|
const btnInstall = el("button", {
|
||||||
|
onclick: async () => {
|
||||||
|
btnInstall.disabled = true;
|
||||||
|
btnInstall.textContent = "Working...";
|
||||||
|
await apiPost({ op: "install", package_id: pkg.id });
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
}, [pkg.installed ? "Reinstall" : "Install"]);
|
||||||
|
|
||||||
|
const btnUpdate = el("button", {
|
||||||
|
onclick: async () => {
|
||||||
|
btnUpdate.disabled = true;
|
||||||
|
btnUpdate.textContent = "Working...";
|
||||||
|
await apiPost({ op: "update", package_id: pkg.id });
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
}, ["Update"]);
|
||||||
|
|
||||||
|
// Update-Button nur wenn installiert
|
||||||
|
btnUpdate.disabled = !pkg.installed;
|
||||||
|
|
||||||
|
const actions = el("div", { class: "actions" }, [btnInstall, btnUpdate]);
|
||||||
|
|
||||||
|
return el("div", { class: "card" }, [
|
||||||
|
el("div", { class: "row" }, [title, installedBadge]),
|
||||||
|
ver,
|
||||||
|
actions
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const status = document.getElementById("status");
|
||||||
const list = document.getElementById("list");
|
const list = document.getElementById("list");
|
||||||
|
|
||||||
|
status.textContent = "Loading...";
|
||||||
list.innerHTML = "";
|
list.innerHTML = "";
|
||||||
|
|
||||||
data.packages.forEach(p => {
|
const data = await apiGet();
|
||||||
const div = document.createElement("div");
|
status.textContent = `Store: ${data.store_url}`;
|
||||||
div.className = "card";
|
|
||||||
div.innerHTML = `
|
for (const pkg of data.packages) {
|
||||||
<div class="row">
|
list.appendChild(card(pkg));
|
||||||
<div>
|
}
|
||||||
<b>${p.name}</b>
|
|
||||||
<div class="muted">${p.repo}</div>
|
|
||||||
</div>
|
|
||||||
<div class="badge">${p.installed ? "Installed" : "Not installed"}</div>
|
|
||||||
</div>
|
|
||||||
<div class="muted">
|
|
||||||
Installed: ${p.installed_version || "-"} |
|
|
||||||
Latest: ${p.latest_version || "-"}
|
|
||||||
${p.release_url ? `| <a href="${p.release_url}" target="_blank">Release</a>` : ""}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
list.appendChild(div);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("refresh").onclick = load;
|
document.getElementById("refresh").onclick = load;
|
||||||
|
|||||||
@@ -9,12 +9,18 @@
|
|||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<h1>Bahmcloud Store</h1>
|
<h1>Bahmcloud Store</h1>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
Installation & Updates laufen manuell über <b>Einstellungen → System → Updates</b>.<br/>
|
Installation erfolgt hier im Store (Buttons).<br/>
|
||||||
Diese Seite zeigt nur die Paketliste aus store.yaml (auto-refresh).
|
Updates erscheinen danach zusätzlich unter <b>Einstellungen → System → Updates</b>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
<button id="refresh">Refresh</button>
|
<button id="refresh">Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status" class="muted"></div>
|
||||||
<div id="list"></div>
|
<div id="list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/api/bahmcloud_store_static/app.js"></script>
|
<script src="/api/bahmcloud_store_static/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,24 +1,472 @@
|
|||||||
class BahmcloudStorePanel extends HTMLElement {
|
class BahmcloudStorePanel extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.attachShadow({ mode: "open" });
|
||||||
|
|
||||||
|
this._hass = null;
|
||||||
|
this._view = "store"; // store | manage | about
|
||||||
|
this._data = null;
|
||||||
|
this._loading = true;
|
||||||
|
this._error = null;
|
||||||
|
|
||||||
|
this._customAddUrl = "";
|
||||||
|
this._customAddName = "";
|
||||||
|
}
|
||||||
|
|
||||||
set hass(hass) {
|
set hass(hass) {
|
||||||
if (this._rendered) return;
|
this._hass = hass;
|
||||||
|
if (!this._rendered) {
|
||||||
this._rendered = true;
|
this._rendered = true;
|
||||||
|
this._render();
|
||||||
|
this._load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const root = this.attachShadow({ mode: "open" });
|
async _load() {
|
||||||
const iframe = document.createElement("iframe");
|
if (!this._hass) return;
|
||||||
|
|
||||||
iframe.src = "/api/bahmcloud_store_static/index.html";
|
this._loading = true;
|
||||||
iframe.style.width = "100%";
|
this._error = null;
|
||||||
iframe.style.height = "100%";
|
this._update();
|
||||||
iframe.style.border = "0";
|
|
||||||
iframe.style.display = "block";
|
|
||||||
|
|
||||||
const style = document.createElement("style");
|
try {
|
||||||
style.textContent = `
|
const data = await this._hass.callApi("get", "bcs");
|
||||||
:host { display: block; height: 100vh; }
|
this._data = data;
|
||||||
|
} catch (e) {
|
||||||
|
this._error = e?.message ? String(e.message) : String(e);
|
||||||
|
} finally {
|
||||||
|
this._loading = false;
|
||||||
|
this._update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_render() {
|
||||||
|
const root = this.shadowRoot;
|
||||||
|
|
||||||
|
root.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
--bcs-accent: #1E88E5; /* Bahmcloud Blue (v1 default) */
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--secondary-text-color);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 600;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
background: var(--card-background-color);
|
||||||
|
color: var(--primary-text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
border-color: var(--bcs-accent);
|
||||||
|
background: color-mix(in srgb, var(--bcs-accent) 16%, var(--card-background-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
background: var(--card-background-color);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.custom {
|
||||||
|
border-color: var(--bcs-accent);
|
||||||
|
color: var(--bcs-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #b00020;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
background: var(--card-background-color);
|
||||||
|
color: var(--primary-text-color);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
border-color: var(--bcs-accent);
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--bcs-accent) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--bcs-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<div>
|
||||||
|
<div class="title">Bahmcloud Store</div>
|
||||||
|
<div class="subtitle">BCS 0.2.0 — foundation build</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button id="refreshBtn">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="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>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
root.appendChild(style);
|
root.getElementById("refreshBtn").addEventListener("click", () => this._load());
|
||||||
root.appendChild(iframe);
|
|
||||||
|
for (const tab of root.querySelectorAll(".tab")) {
|
||||||
|
tab.addEventListener("click", () => {
|
||||||
|
this._view = tab.getAttribute("data-view");
|
||||||
|
this._update();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_update() {
|
||||||
|
const root = this.shadowRoot;
|
||||||
|
const content = root.getElementById("content");
|
||||||
|
const err = root.getElementById("error");
|
||||||
|
|
||||||
|
for (const tab of root.querySelectorAll(".tab")) {
|
||||||
|
tab.classList.toggle("active", tab.getAttribute("data-view") === this._view);
|
||||||
|
}
|
||||||
|
|
||||||
|
err.textContent = this._error ? `Error: ${this._error}` : "";
|
||||||
|
|
||||||
|
if (this._loading) {
|
||||||
|
content.innerHTML = `<div class="card">Loading…</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._data) {
|
||||||
|
content.innerHTML = `<div class="card">No data.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._view === "store") {
|
||||||
|
content.innerHTML = this._renderStore();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._view === "manage") {
|
||||||
|
content.innerHTML = this._renderManage();
|
||||||
|
this._wireManage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
content.innerHTML = this._renderAbout();
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderStore() {
|
||||||
|
const repos = Array.isArray(this._data.repos) ? this._data.repos : [];
|
||||||
|
|
||||||
|
const rows = repos.map((r) => {
|
||||||
|
const badge = r.source === "custom"
|
||||||
|
? `<span class="badge custom">Custom</span>`
|
||||||
|
: `<span class="badge">Index</span>`;
|
||||||
|
|
||||||
|
const owner = r.owner ? `Owner: ${this._esc(r.owner)}` : "Owner: -";
|
||||||
|
const provider = r.provider ? `Provider: ${this._esc(r.provider)}` : "Provider: -";
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="card">
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<div><strong>${this._esc(r.name)}</strong></div>
|
||||||
|
<div class="muted">${this._esc(r.description || "Description will be loaded in a later version.")}</div>
|
||||||
|
<div class="muted small">${owner} · ${provider}</div>
|
||||||
|
<div class="muted small"><a href="${this._esc(r.url)}" target="_blank" rel="noreferrer">Open repository</a></div>
|
||||||
|
</div>
|
||||||
|
${badge}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="card">
|
||||||
|
<div><strong>Store</strong></div>
|
||||||
|
<div class="muted small">Index URL: ${this._esc(this._data.store_url || "-")}</div>
|
||||||
|
<div class="muted small">Refresh seconds: ${this._esc(String(this._data.refresh_seconds || "-"))}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${rows || `<div class="card">No repositories configured.</div>`}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderManage() {
|
||||||
|
const repos = Array.isArray(this._data.repos) ? this._data.repos : [];
|
||||||
|
const custom = repos.filter((r) => r.source === "custom");
|
||||||
|
|
||||||
|
const list = custom.map((r) => {
|
||||||
|
return `
|
||||||
|
<div class="card">
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<div><strong>${this._esc(r.name)}</strong></div>
|
||||||
|
<div class="muted">${this._esc(r.url)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="primary" data-remove="${this._esc(r.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 class="grid" style="margin-top: 12px;">
|
||||||
|
<div class="field">
|
||||||
|
<label class="muted small">Repository URL</label>
|
||||||
|
<input id="addUrl" placeholder="https://github.com/user/repo" value="${this._esc(this._customAddUrl)}" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="muted small">Display name (optional)</label>
|
||||||
|
<input id="addName" placeholder="My Integration" value="${this._esc(this._customAddName)}" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<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() {
|
||||||
|
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(this._data.version || "-")}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div><strong>Roadmap</strong></div>
|
||||||
|
<div class="muted">
|
||||||
|
Next versions will add: repo metadata (bcs.yaml / hacs.*), README view, install/uninstall, update entities.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_esc(s) {
|
||||||
|
return String(s ?? "")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
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; }
|
.row { display:flex; justify-content:space-between; gap: 12px; align-items: flex-start; }
|
||||||
.badge { border: 1px solid #bbb; border-radius: 999px; padding: 2px 8px; font-size: 12px; }
|
.badge { border: 1px solid #bbb; border-radius: 999px; padding: 2px 8px; font-size: 12px; height: fit-content; }
|
||||||
.muted { color: #666; font-size: 13px; }
|
.muted { color: #666; font-size: 13px; margin-top: 4px; }
|
||||||
|
.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; }
|
||||||
a { color: inherit; }
|
a { color: inherit; }
|
||||||
|
|||||||
77
custom_components/bahmcloud_store/storage.py
Normal file
77
custom_components/bahmcloud_store/storage.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
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)
|
||||||
@@ -35,7 +35,7 @@ class StoreConfig:
|
|||||||
class Package:
|
class Package:
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
type: str
|
type: str # "integration" | "store"
|
||||||
domain: str
|
domain: str
|
||||||
repo: str
|
repo: str
|
||||||
owner: str
|
owner: str
|
||||||
@@ -76,12 +76,19 @@ class BahmcloudStore:
|
|||||||
u = urlparse(repo_url.rstrip("/"))
|
u = urlparse(repo_url.rstrip("/"))
|
||||||
return f"{u.scheme}://{u.netloc}"
|
return f"{u.scheme}://{u.netloc}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _raw_manifest_url(repo: str, branch: str, source_path: str) -> str:
|
||||||
|
# Example:
|
||||||
|
# https://git.bahmcloud.de/bahmcloud/easy_proxmox/raw/branch/main/custom_components/easy_proxmox/manifest.json
|
||||||
|
return f"{repo.rstrip('/')}/raw/branch/{branch}/{source_path.rstrip('/')}/manifest.json"
|
||||||
|
|
||||||
async def _fetch_latest_version(self, pkg: Package) -> tuple[str | None, str | None]:
|
async def _fetch_latest_version(self, pkg: Package) -> tuple[str | None, str | None]:
|
||||||
"""
|
"""
|
||||||
Returns (latest_version, release_url)
|
Returns (latest_version, release_url)
|
||||||
Strategy:
|
Strategy:
|
||||||
1) releases/latest -> tag_name
|
1) releases/latest -> tag_name
|
||||||
2) tags?limit=1 -> first tag name
|
2) tags?limit=1 -> first tag name
|
||||||
|
3) fallback: read manifest.json from repo (version field)
|
||||||
"""
|
"""
|
||||||
session = async_get_clientsession(self.hass)
|
session = async_get_clientsession(self.hass)
|
||||||
base = self._base_from_repo(pkg.repo)
|
base = self._base_from_repo(pkg.repo)
|
||||||
@@ -94,7 +101,8 @@ class BahmcloudStore:
|
|||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
tag = data.get("tag_name")
|
tag = data.get("tag_name")
|
||||||
html_url = data.get("html_url")
|
html_url = data.get("html_url")
|
||||||
return (str(tag) if tag else None, str(html_url) if html_url else None)
|
if tag:
|
||||||
|
return (str(tag), str(html_url) if html_url else None)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -106,7 +114,21 @@ class BahmcloudStore:
|
|||||||
tags = await resp.json()
|
tags = await resp.json()
|
||||||
if tags and isinstance(tags, list):
|
if tags and isinstance(tags, list):
|
||||||
name = tags[0].get("name")
|
name = tags[0].get("name")
|
||||||
return (str(name) if name else None, None)
|
if name:
|
||||||
|
return (str(name), None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3) fallback: manifest.json version from repo
|
||||||
|
try:
|
||||||
|
manifest_url = self._raw_manifest_url(pkg.repo, pkg.branch, pkg.source_path)
|
||||||
|
async with session.get(manifest_url, timeout=20) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
text = await resp.text()
|
||||||
|
data = json.loads(text)
|
||||||
|
ver = data.get("version")
|
||||||
|
if ver:
|
||||||
|
return (str(ver), None)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -198,6 +220,9 @@ class BahmcloudStore:
|
|||||||
shutil.rmtree(target)
|
shutil.rmtree(target)
|
||||||
shutil.copytree(src, target)
|
shutil.copytree(src, target)
|
||||||
|
|
||||||
|
# Nach Installation: Entities neu aufbauen (damit es als Update auftaucht)
|
||||||
|
self.signal_entities_updated()
|
||||||
|
|
||||||
persistent_notification.async_create(
|
persistent_notification.async_create(
|
||||||
self.hass,
|
self.hass,
|
||||||
(
|
(
|
||||||
@@ -228,7 +253,7 @@ class BahmcloudStore:
|
|||||||
async def register_http_views(self) -> None:
|
async def register_http_views(self) -> None:
|
||||||
"""Register HTTP views for static panel assets and JSON API."""
|
"""Register HTTP views for static panel assets and JSON API."""
|
||||||
self.hass.http.register_view(_StaticView())
|
self.hass.http.register_view(_StaticView())
|
||||||
self.hass.http.register_view(_APIListView(self))
|
self.hass.http.register_view(_APIView(self))
|
||||||
|
|
||||||
|
|
||||||
class _StaticView(HomeAssistantView):
|
class _StaticView(HomeAssistantView):
|
||||||
@@ -236,12 +261,6 @@ class _StaticView(HomeAssistantView):
|
|||||||
IMPORTANT:
|
IMPORTANT:
|
||||||
Custom Panel JS modules are loaded WITHOUT Authorization headers.
|
Custom Panel JS modules are loaded WITHOUT Authorization headers.
|
||||||
Therefore static panel assets must be publicly accessible (no auth).
|
Therefore static panel assets must be publicly accessible (no auth).
|
||||||
|
|
||||||
Serves:
|
|
||||||
/api/bahmcloud_store_static/index.html
|
|
||||||
/api/bahmcloud_store_static/panel.js
|
|
||||||
/api/bahmcloud_store_static/app.js
|
|
||||||
/api/bahmcloud_store_static/styles.css
|
|
||||||
"""
|
"""
|
||||||
requires_auth = False
|
requires_auth = False
|
||||||
name = "bahmcloud_store_static"
|
name = "bahmcloud_store_static"
|
||||||
@@ -254,7 +273,6 @@ class _StaticView(HomeAssistantView):
|
|||||||
|
|
||||||
f = (base / path).resolve()
|
f = (base / path).resolve()
|
||||||
|
|
||||||
# Prevent path traversal
|
|
||||||
if not str(f).startswith(str(base)) or not f.exists() or not f.is_file():
|
if not str(f).startswith(str(base)) or not f.exists() or not f.is_file():
|
||||||
return web.Response(status=404, text="Not found")
|
return web.Response(status=404, text="Not found")
|
||||||
|
|
||||||
@@ -269,10 +287,11 @@ class _StaticView(HomeAssistantView):
|
|||||||
return web.Response(body=f.read_bytes(), content_type="application/octet-stream")
|
return web.Response(body=f.read_bytes(), content_type="application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
class _APIListView(HomeAssistantView):
|
class _APIView(HomeAssistantView):
|
||||||
"""
|
"""
|
||||||
Store API MUST stay protected.
|
Auth-protected API:
|
||||||
UI loads data via fetch() with HA auth handled by frontend.
|
GET /api/bahmcloud_store -> list packages
|
||||||
|
POST /api/bahmcloud_store {op:...} -> install/update a package
|
||||||
"""
|
"""
|
||||||
requires_auth = True
|
requires_auth = True
|
||||||
name = "bahmcloud_store_api"
|
name = "bahmcloud_store_api"
|
||||||
@@ -300,3 +319,20 @@ class _APIListView(HomeAssistantView):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
return self.json({"packages": items, "store_url": self.store.config.store_url})
|
return self.json({"packages": items, "store_url": self.store.config.store_url})
|
||||||
|
|
||||||
|
async def post(self, request):
|
||||||
|
data = await request.json()
|
||||||
|
op = data.get("op")
|
||||||
|
package_id = data.get("package_id")
|
||||||
|
|
||||||
|
if op not in ("install", "update"):
|
||||||
|
return self.json({"error": "unknown op"}, status_code=400)
|
||||||
|
if not package_id:
|
||||||
|
return self.json({"error": "package_id missing"}, status_code=400)
|
||||||
|
|
||||||
|
pkg = self.store.packages.get(package_id)
|
||||||
|
if not pkg:
|
||||||
|
return self.json({"error": "unknown package_id"}, status_code=404)
|
||||||
|
|
||||||
|
await self.store.install_from_zip(pkg)
|
||||||
|
return self.json({"ok": True})
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
from __future__ import annotations
|
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.2.0.
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from . import DOMAIN
|
|
||||||
from .store import BahmcloudStore, Package
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_platform(
|
async def async_setup_platform(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
@@ -14,71 +14,4 @@ async def async_setup_platform(
|
|||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
discovery_info=None,
|
discovery_info=None,
|
||||||
):
|
):
|
||||||
store: BahmcloudStore = hass.data[DOMAIN]
|
|
||||||
entities: dict[str, BahmcloudPackageUpdate] = {}
|
|
||||||
|
|
||||||
def rebuild_entities() -> None:
|
|
||||||
# create entities for any new package in store.yaml
|
|
||||||
for pkg in store.packages.values():
|
|
||||||
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
|
return
|
||||||
await self.store.install_from_zip(pkg)
|
|
||||||
self.async_write_ha_state()
|
|
||||||
|
|||||||
82
custom_components/bahmcloud_store/views.py
Normal file
82
custom_components/bahmcloud_store/views.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
from homeassistant.components.http import HomeAssistantView
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .core import BCSCore
|
||||||
|
|
||||||
|
|
||||||
|
class StaticAssetsView(HomeAssistantView):
|
||||||
|
"""
|
||||||
|
Static panel assets MUST be public (no auth), because Home Assistant loads
|
||||||
|
custom panel JS modules without auth headers.
|
||||||
|
"""
|
||||||
|
requires_auth = False
|
||||||
|
name = "bahmcloud_store_static"
|
||||||
|
url = "/api/bahmcloud_store_static/{path:.*}"
|
||||||
|
|
||||||
|
async def get(self, request, path):
|
||||||
|
base = Path(__file__).resolve().parent / "panel"
|
||||||
|
if not path:
|
||||||
|
path = "panel.js"
|
||||||
|
|
||||||
|
f = (base / path).resolve()
|
||||||
|
|
||||||
|
# Prevent path traversal
|
||||||
|
if not str(f).startswith(str(base)) or not f.exists() or not f.is_file():
|
||||||
|
return web.Response(status=404, text="Not found")
|
||||||
|
|
||||||
|
suffix = f.suffix.lower()
|
||||||
|
if suffix == ".js":
|
||||||
|
return web.Response(body=f.read_bytes(), content_type="application/javascript")
|
||||||
|
if suffix == ".css":
|
||||||
|
return web.Response(body=f.read_bytes(), content_type="text/css")
|
||||||
|
if suffix in (".html", ".htm"):
|
||||||
|
return web.Response(body=f.read_bytes(), content_type="text/html")
|
||||||
|
|
||||||
|
return web.Response(body=f.read_bytes(), content_type="application/octet-stream")
|
||||||
|
|
||||||
|
|
||||||
|
class BCSApiView(HomeAssistantView):
|
||||||
|
"""
|
||||||
|
BCS API (auth required)
|
||||||
|
- GET /api/bcs
|
||||||
|
- POST /api/bcs (op=add_custom_repo)
|
||||||
|
"""
|
||||||
|
requires_auth = True
|
||||||
|
name = "bcs_api"
|
||||||
|
url = "/api/bcs"
|
||||||
|
|
||||||
|
def __init__(self, core: "BCSCore") -> None:
|
||||||
|
self.core = core
|
||||||
|
|
||||||
|
async def get(self, request):
|
||||||
|
return self.json(
|
||||||
|
{
|
||||||
|
"repos": self.core.list_repos_public(),
|
||||||
|
"store_url": self.core.config.store_url,
|
||||||
|
"refresh_seconds": self.core.refresh_seconds,
|
||||||
|
"version": "0.2.0",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def post(self, request):
|
||||||
|
data = await request.json()
|
||||||
|
op = str(data.get("op") or "").strip()
|
||||||
|
|
||||||
|
if op != "add_custom_repo":
|
||||||
|
return self.json({"error": "unknown op"}, status_code=400)
|
||||||
|
|
||||||
|
url = str(data.get("url") or "").strip()
|
||||||
|
name = data.get("name")
|
||||||
|
name = str(name).strip() if isinstance(name, str) and name.strip() else None
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
return self.json({"error": "url missing"}, status_code=400)
|
||||||
|
|
||||||
|
repo = await self.core.add_custom_repo(url=url, name=name)
|
||||||
|
return self.json({"ok": True, "repo": {"id": repo.id, "url": repo.url, "name": repo.name}})
|
||||||
Reference in New Issue
Block a user