19 Commits

Author SHA1 Message Date
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
184b8f9a1d custom_components/bahmcloud_store/panel/styles.css aktualisiert 2026-01-14 18:48:35 +00:00
936b5d9e7f custom_components/bahmcloud_store/panel/app.js aktualisiert 2026-01-14 18:48:20 +00:00
04ba73a29c custom_components/bahmcloud_store/panel/index.html aktualisiert 2026-01-14 18:48:06 +00:00
5ded45c3f8 custom_components/bahmcloud_store/update.py aktualisiert 2026-01-14 18:47:46 +00:00
7b748a0f0b custom_components/bahmcloud_store/store.py aktualisiert 2026-01-14 18:47:12 +00:00
14 changed files with 1054 additions and 151 deletions

35
CHANGELOG.md Normal file
View 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.

View File

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

View File

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

View 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

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,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"
} }

View File

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

View File

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

View File

@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
} }
} }

View File

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

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

View File

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

View File

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

View 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}})