Compare commits

..

2 Commits

Author SHA1 Message Date
f8801e469a Polish v1 dashboard loading and layout
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 07:33:30 +02:00
787516ac67 Avoid per-request discovery classification in dashboard
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 01:19:59 +02:00
7 changed files with 256 additions and 117 deletions

View File

@@ -1,5 +1,15 @@
# Changelog
## 1.0.1 - 2026-06-17
- Dashboard-UI nach v1-Korrektur neu strukturiert: feste Steuerungsleiste,
separate Geräteübersicht, klare Freigabe-/Detailfläche und Statusbereich.
- Orange bleibt Primärfarbe; Cyan ist die sichtbare Komplementärfarbe. Rote
Aktions- und Fehlerflächen wurden aus der Oberfläche entfernt.
- Startpfad weiter beschleunigt: Dashboard lädt nur noch lokale Startdaten.
HA-Discovery, Vorschläge und Automation-Refresh laufen erst nach Nutzeraktion.
- Detailansicht öffnet ohne automatische Automation-Discovery. Passende
Automationen können gezielt per Button neu gesucht werden.
## 1.0.0 - 2026-06-17
- Neuer blockweiser Dashboard-Start über `/v1/actuators/dashboard`: lokale
Store-/Cache-Daten laden sofort, HA-Discovery und Vorschläge laufen

View File

@@ -1,5 +1,5 @@
name: SillyHome Next
version: "1.0.0"
version: "1.0.1"
slug: sillyhome_next
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
url: http://192.168.6.31:3000/pino/sillyhome-next

View File

@@ -208,7 +208,11 @@ def context_options(
@router.get("/summary", response_model=list[ActuatorSummary])
def list_configured_summary(request: Request) -> list[ActuatorSummary]:
entity_map = {entity.entity_id: entity for entity in _load_cached_entities(request)}
records = _service(request).list_configured()
entity_map = _load_cached_entity_map(
request,
{record.actuator_entity_id for record in records},
)
return [
ActuatorSummary(
actuator_entity_id=record.actuator_entity_id,
@@ -247,7 +251,7 @@ def list_configured_summary(request: Request) -> list[ActuatorSummary]:
),
updated_at=record.updated_at.isoformat(),
)
for record in _service(request).list_configured()
for record in records
]
@@ -259,16 +263,12 @@ def dashboard_overview(request: Request) -> DashboardOverview:
raw_entities = []
raw_updated_at = cache_payload.get("updated_at")
updated_at = raw_updated_at if isinstance(raw_updated_at, str) else None
cached_entities = [
HaEntitySummary.model_validate(entity)
for entity in raw_entities
if isinstance(entity, dict)
]
discovered = discover_entities(cached_entities) if cached_entities else []
group_counts: dict[tuple[str, str], int] = {}
for entity in discovered:
key = (entity.category, entity.role.value)
group_counts[key] = group_counts.get(key, 0) + 1
raw_groups = cache_payload.get("discovery_groups", [])
cached_groups = [
DashboardDiscoveryGroup.model_validate(group)
for group in raw_groups
if isinstance(group, dict)
] if isinstance(raw_groups, list) else []
reconciliation = _reconciliation_state_or_default(request)
ws_status = getattr(request.app.state, "ws_status", None)
actuators = list_configured_summary(request)
@@ -286,15 +286,12 @@ def dashboard_overview(request: Request) -> DashboardOverview:
review_required=reconciliation.review_required,
),
cache=EntityCacheStatus(
available=bool(cached_entities),
available=bool(raw_entities),
updated_at=updated_at,
entity_count=len(cached_entities),
entity_count=len(raw_entities),
),
actuators=actuators,
discovery_groups=[
DashboardDiscoveryGroup(category=category, role=role, count=count)
for (category, role), count in sorted(group_counts.items())
],
discovery_groups=cached_groups,
)
@@ -521,6 +518,30 @@ def _load_cached_entities(request: Request) -> list[HaEntitySummary]:
return []
def _load_cached_entity_map(
request: Request,
entity_ids: set[str],
) -> dict[str, HaEntitySummary]:
if not entity_ids:
return {}
payload = _load_entity_cache_payload(request)
raw_entities = payload.get("entities", [])
if not isinstance(raw_entities, list):
return {}
result: dict[str, HaEntitySummary] = {}
for raw_entity in raw_entities:
if not isinstance(raw_entity, dict):
continue
entity_id = raw_entity.get("entity_id")
if not isinstance(entity_id, str) or entity_id not in entity_ids:
continue
try:
result[entity_id] = HaEntitySummary.model_validate(raw_entity)
except ValueError:
continue
return result
def _load_entity_cache_payload(request: Request) -> dict[str, object]:
path = _entity_cache_path(request)
if not path.exists():
@@ -535,8 +556,16 @@ def _load_entity_cache_payload(request: Request) -> dict[str, object]:
def _save_cached_entities(request: Request, entities: list[HaEntitySummary]) -> None:
path = _entity_cache_path(request)
path.parent.mkdir(parents=True, exist_ok=True)
group_counts: dict[tuple[str, str], int] = {}
for entity in discover_entities(entities):
key = (entity.category, entity.role.value)
group_counts[key] = group_counts.get(key, 0) + 1
payload = {
"updated_at": datetime.now(timezone.utc).isoformat(),
"discovery_groups": [
{"category": category, "role": role, "count": count}
for (category, role), count in sorted(group_counts.items())
],
"entities": [entity.model_dump(mode="json") for entity in entities],
}
temporary = path.with_suffix(".json.tmp")

View File

@@ -105,7 +105,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app = FastAPI(
title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="1.0.0",
version="1.0.1",
lifespan=lifespan,
)
app.state.settings = load_settings()

View File

@@ -5,18 +5,51 @@
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SillyHome Next</title>
<style>
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #111317; color: #f3f5f7; scroll-behavior:smooth; --accent:#ff8a1c; --accent-strong:#ffab45; --panel:#181c22; --panel-soft:#20262e; --border:#303842; }
body { margin: 0; font-size:15px; }
header { padding: 18px 20px; background: linear-gradient(135deg,#1c1f26,#3a2411 70%,#5c2d08); }
h1,h2,h3 { margin: 0 0 12px; }
header p { margin: 5px 0; color: #c3d1dc; }
main { display: grid; grid-template-columns: minmax(280px,.82fr) minmax(0,1.38fr); gap: 12px; padding: 12px; max-width:1380px; margin:0 auto; }
section { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 12px; min-width:0; }
section:target { outline:2px solid var(--accent); outline-offset:2px; }
.wide { grid-column: 1 / -1; }
.quick-nav { position:sticky; top:0; z-index:10; display:flex; gap:8px; overflow-x:auto; padding:10px 14px; background:rgba(17,19,23,.94); border-bottom:1px solid var(--border); backdrop-filter:blur(8px); }
.quick-nav a { flex:0 0 auto; padding:10px 12px; border-radius:999px; background:#222831; border:1px solid var(--border); color:#f3f5f7; text-decoration:none; font-weight:700; font-size:.92rem; }
.quick-nav a.primary { background:#8f4208; border-color:var(--accent); }
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
background:#0e1218;
color:#f4f7fb;
scroll-behavior:smooth;
--accent:#ff8a1c;
--accent-strong:#ffb45b;
--complement:#1cc7ff;
--complement-soft:#123447;
--panel:#171d25;
--panel-soft:#202833;
--panel-quiet:#111720;
--border:#314050;
--text-soft:#aebdcc;
--ok:#53e0a5;
--warn:#f3c969;
--bad:#8fb8ff;
}
* { box-sizing:border-box; }
body { margin:0; font-size:15px; background:var(--panel-quiet); }
h1,h2,h3 { margin:0 0 10px; letter-spacing:0; }
p { margin:6px 0; }
header { padding:18px 18px 14px; border-bottom:1px solid var(--border); background:#121821; }
.topbar { max-width:1480px; margin:0 auto; display:flex; align-items:flex-start; justify-content:space-between; gap:16px; }
.brand { display:grid; gap:5px; min-width:0; }
.brand-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
.brand-mark { width:34px; height:34px; border-radius:8px; display:grid; place-items:center; background:var(--accent); color:#201204; font-weight:900; }
header p { color:var(--text-soft); max-width:820px; }
.status-pill { display:flex; align-items:center; gap:8px; padding:8px 10px; border:1px solid var(--border); border-radius:8px; background:#101722; color:#d9e6f0; white-space:nowrap; }
.dot { width:9px; height:9px; border-radius:50%; background:var(--complement); box-shadow:0 0 0 3px rgba(28,199,255,.15); }
.quick-nav { position:sticky; top:0; z-index:10; display:flex; gap:8px; overflow-x:auto; padding:10px 18px; background:rgba(14,18,24,.96); border-bottom:1px solid var(--border); backdrop-filter:blur(8px); }
.quick-nav a { flex:0 0 auto; min-height:36px; display:grid; place-items:center; padding:8px 12px; border-radius:8px; background:#151c25; border:1px solid var(--border); color:#f3f7fb; text-decoration:none; font-weight:750; font-size:.92rem; }
.quick-nav a.primary { background:var(--accent); border-color:var(--accent); color:#211204; }
main { display:grid; grid-template-columns:minmax(270px,.72fr) minmax(0,1.58fr); grid-template-areas:"control board" "control detail" "status status" "guide guide"; gap:12px; padding:12px; max-width:1480px; margin:0 auto; }
section { background:var(--panel); border:1px solid var(--border); border-radius:8px; padding:12px; min-width:0; }
section:target { outline:2px solid var(--complement); outline-offset:2px; }
.control-panel { grid-area:control; align-self:start; position:sticky; top:58px; }
.board-panel { grid-area:board; }
.detail-panel { grid-area:detail; }
.status-panel { grid-area:status; }
.guide-panel { grid-area:guide; }
.panel-title { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:8px; }
.toolbar { display:flex; flex-wrap:wrap; gap:8px; align-items:center; margin:10px 0; }
.toolbar button { margin-top:0; }
details.collapsible > summary,
.manual-context > summary,
.group-panel > summary { cursor:pointer; font-weight:800; color:#eaf1f8; }
@@ -31,30 +64,30 @@
.manual-context[open] > summary::after,
.group-panel[open] > summary::after { content:"zuklappen"; }
.steps { display:grid; grid-template-columns:repeat(auto-fit,minmax(190px,1fr)); gap:10px; margin-top:12px; }
.step { background:#12161c; border:1px solid var(--border); border-radius:8px; padding:10px; }
.step-number { display:inline-grid; place-items:center; width:28px; height:28px; border-radius:50%; background:var(--accent); color:#211204; font-weight:800; margin-bottom:8px; }
.step { background:#121922; border:1px solid var(--border); border-radius:8px; padding:10px; }
.step-number { display:inline-grid; place-items:center; width:28px; height:28px; border-radius:8px; background:var(--complement); color:#03151d; font-weight:900; margin-bottom:8px; }
.step p { margin:5px 0; }
.ok { color: #73e0a9; }
.warn { color: #f3c969; }
.bad { color: #ff8f8f; }
label { display: block; margin: 9px 0 4px; color: #b9c9d6; }
select,input,button { box-sizing: border-box; width: 100%; border-radius: 8px; border: 1px solid #3b4b5b; padding: 10px; background: #11161d; color: #fff; font:inherit; min-width:0; }
.bad { color: var(--bad); }
label { display:block; margin:9px 0 4px; color:#c3d2df; font-weight:700; }
select,input,button { width:100%; border-radius:8px; border:1px solid #3b4b5b; padding:10px; background:#111821; color:#fff; font:inherit; min-width:0; }
select[multiple] { min-height:150px; }
button { min-height:44px; margin-top: 10px; background: var(--accent); color:#211204; border: 0; font-weight: 800; cursor: pointer; }
button.secondary { background: #37495c; }
button.danger { background: #7b3434; }
button.compact { width:auto; min-width:120px; margin-right:8px; }
button { min-height:42px; margin-top:10px; background:var(--accent); color:#211204; border:0; font-weight:850; cursor:pointer; }
button.secondary { background:var(--complement-soft); color:#dff6ff; border:1px solid #22607c; }
button.danger { background:#2a3441; color:#f2f6fb; border:1px solid #536273; }
button.compact { width:auto; min-width:112px; margin-right:8px; padding:8px 10px; min-height:36px; }
table { width: 100%; border-collapse: collapse; font-size: .92rem; }
td,th { padding: 8px; border-bottom: 1px solid #2d3a47; text-align: left; vertical-align: top; }
ul { margin: 8px 0; padding-left: 18px; }
.notice { border-left: 4px solid var(--accent); padding-left: 10px; }
.notice { border-left:4px solid var(--complement); padding-left:10px; }
.grid-two { display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:12px; }
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
.chip { padding:4px 8px; border-radius:999px; background:#222831; border:1px solid var(--border); font-size:.85rem; }
.muted { color:#9fb0be; }
.card-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
.actuator-card { background:#12161c; border:1px solid var(--border); border-radius:8px; padding:10px; min-width:0; }
.actuator-card.selected { border-color:var(--accent); box-shadow:0 0 0 1px rgba(255,138,28,.35); }
.chip { padding:4px 8px; border-radius:8px; background:#222b36; border:1px solid var(--border); font-size:.85rem; }
.muted { color:var(--text-soft); }
.card-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(250px,1fr)); gap:10px; }
.actuator-card { background:#121922; border:1px solid var(--border); border-radius:8px; padding:10px; min-width:0; }
.actuator-card.selected { border-color:var(--complement); box-shadow:0 0 0 1px rgba(28,199,255,.35); }
.card-title { display:flex; justify-content:space-between; gap:10px; align-items:flex-start; margin-bottom:8px; }
.entity-id { overflow-wrap:anywhere; font-weight:800; }
.metric-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:6px; margin:8px 0; }
@@ -63,16 +96,20 @@
.actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; }
.actions button { flex:1 1 180px; margin-top:0; }
.detail-header { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; flex-wrap:wrap; }
.manual-context { margin-top:12px; background:#12161c; border:1px solid var(--border); border-radius:8px; padding:10px; }
.manual-context { margin-top:12px; background:#121922; border:1px solid var(--border); border-radius:8px; padding:10px; }
.inline-controls { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:8px; margin:8px 0; }
.manual-entry { min-height:80px; resize:vertical; }
textarea { box-sizing:border-box; width:100%; border-radius:10px; border:1px solid #3b4b5b; padding:12px; background:#101820; color:#fff; font:inherit; }
textarea { width:100%; border-radius:8px; border:1px solid #3b4b5b; padding:12px; background:#101820; color:#fff; font:inherit; }
optgroup { color:#cfe0ec; background:#101820; }
code { color:#cfe0ec; overflow-wrap:anywhere; }
.empty-state { min-height:140px; display:grid; place-items:center; text-align:center; border:1px dashed var(--border); border-radius:8px; background:#111821; color:var(--text-soft); padding:16px; }
@media (max-width: 760px) {
header { padding:18px 14px; }
header { padding:16px 12px; }
header h1 { font-size:1.55rem; }
.topbar { display:grid; }
.status-pill { width:max-content; }
main { display:block; padding:8px; }
.control-panel { position:static; }
section { margin-bottom:10px; padding:10px; border-radius:8px; }
.steps { grid-template-columns:1fr; }
.grid-two { grid-template-columns:1fr; }
@@ -91,55 +128,31 @@
</head>
<body>
<header>
<h1>SillyHome Next</h1>
<p>Hier wählst du nur Geräte aus, deren Bedienung SillyHome lernen soll. Sensoren, Zusammenhänge und Modelle werden automatisch verwaltet.</p>
<p class="notice">Sicherer Start: Zuerst wird nur beobachtet und vorhergesagt. Ohne deine spätere Freigabe wird nichts geschaltet.</p>
<div class="topbar">
<div class="brand">
<div class="brand-row">
<span class="brand-mark">SH</span>
<h1>SillyHome Next</h1>
</div>
<p>Arbeitsdashboard für gelernte Home-Assistant-Bedienung: Geräte auswählen, Lernstand prüfen, Freigaben steuern.</p>
<p class="notice">Sicherer Start: Zuerst wird nur beobachtet und vorhergesagt. Ohne deine spätere Freigabe wird nichts geschaltet.</p>
</div>
<div class="status-pill"><span class="dot"></span><span id="load-budget">Startdaten laden ...</span></div>
</div>
</header>
<nav class="quick-nav" aria-label="Schnellnavigation">
<a class="primary" href="#choose">Gerät wählen</a>
<a href="#observed">Beobachtet</a>
<a href="#detail">Details</a>
<a href="#status-section">Status</a>
<a class="primary" href="#choose">Steuerung</a>
<a href="#observed">Geräte</a>
<a href="#detail">Freigabe</a>
<a href="#status-section">System</a>
<a href="#guide">Ablauf</a>
</nav>
<main>
<section class="wide" id="guide">
<details class="collapsible">
<summary><span>So gehst du vor</span></summary>
<div class="steps">
<div class="step">
<span class="step-number">1</span>
<h3>Aktor auswählen</h3>
<p><strong>Wo?</strong> Unten im Feld „Gerät auswählen“.</p>
<p><strong>Was passiert?</strong> SillyHome ordnet Raum, Sensoren, Zustände und vorhandene Historie automatisch zu.</p>
</div>
<div class="step">
<span class="step-number">2</span>
<h3>Wie gewohnt bedienen</h3>
<p><strong>Wo?</strong> Weiterhin in Home Assistant, an Schaltern oder über deine bisherigen Bedienwege.</p>
<p><strong>Was passiert?</strong> SillyHome lernt deine Handlungen und zeigt Vorhersagen an, schaltet aber noch nicht selbst.</p>
</div>
<div class="step">
<span class="step-number">3</span>
<h3>Später freigeben</h3>
<p><strong>Wo?</strong> In den Details des ausgewählten Geräts, sobald genug Verhalten gelernt wurde.</p>
<p><strong>Was passiert?</strong> Erst dann darf SillyHome passende Vorhersagen automatisch ausführen. Die Freigabe kann jederzeit gestoppt werden.</p>
</div>
<section class="control-panel" id="choose">
<div class="panel-title">
<h2>Steuerung</h2>
<span class="chip">v1</span>
</div>
</details>
</section>
<section id="status-section">
<h2>System & Cache</h2>
<p class="muted">Die Startansicht nutzt lokale Summaries und Cache-Daten. Home-Assistant-Discovery lädt im Hintergrund nach.</p>
<div id="status">Prüfung läuft ...</div>
<div class="chips" id="status-chips"></div>
<div id="dashboard-stats" class="metric-grid"></div>
<button class="secondary" onclick="loadOverview()">Dashboard aktualisieren</button>
</section>
<section id="choose">
<h2>1. Gerät zum Lernen auswählen</h2>
<p class="muted">Wähle eine Lampe, einen Rollladen oder einen anderen unterstützten Aktor. Du wählst keine Sensoren und erstellst keine Regeln.</p>
<label for="actuator-input">Entitätsname oder Gerät aus Home Assistant</label>
<input id="actuator-input" list="actuator-options" placeholder="z. B. light.licht_abstellraum" autocomplete="off">
@@ -168,29 +181,79 @@
</div>
<div>
<label for="actuator-search">Liste durchsuchen</label>
<input id="actuator-search" placeholder="Raum, Gerät oder Entity" oninput="renderActuatorSelect()" autocomplete="off">
<input id="actuator-search" placeholder="Raum, Gerät oder Entity" oninput="renderActuatorSelect()" onfocus="ensureActuatorDiscovery()" autocomplete="off">
</div>
</div>
<label for="actuator-select">Oder aus Liste wählen</label>
<select id="actuator-select" onchange="selectActuatorFromList()">
<option value="">Geräteliste wird geladen ...</option>
<select id="actuator-select" onchange="selectActuatorFromList()" onfocus="ensureActuatorDiscovery()">
<option value="">Geräteliste bei Bedarf laden</option>
</select>
<button onclick="configureActuator()">Gerät hinzufügen und Beobachtung starten</button>
<button class="secondary" onclick="ensureActuatorDiscovery()">Geräteliste laden</button>
<p id="actuator-config-result" class="muted">Noch kein Aktor ausgewählt.</p>
<details class="manual-context">
<summary>Vorschläge anzeigen</summary>
<p class="muted">Vorschläge können Home Assistant stark abfragen und werden deshalb nicht beim Start geladen.</p>
<button class="secondary" onclick="loadActuatorSuggestions()">Vorschläge laden</button>
</details>
<div id="actuator-suggestions" class="card-list"></div>
</section>
<section class="wide" id="observed">
<h2>2. Beobachtete Geräte</h2>
<p class="muted">Öffne „Details“, um Lernfortschritt, aktuelle Vorhersage und den automatisch gefundenen Kontext zu sehen.</p>
<section class="board-panel" id="observed">
<div class="panel-title">
<div>
<h2>Beobachtete Geräte</h2>
<p class="muted">Öffne „Details“, um Lernfortschritt, aktuelle Vorhersage und den automatisch gefundenen Kontext zu sehen.</p>
</div>
<button class="secondary compact" onclick="loadOverview()">Aktualisieren</button>
</div>
<div id="configured-actuators">Noch nicht geladen.</div>
</section>
<section class="wide" id="detail">
<h2>3. Lernfortschritt und Freigabe</h2>
<section class="detail-panel" id="detail">
<h2>Lernfortschritt und Freigabe</h2>
<p class="muted">Die Freigabe erscheint erst, wenn genug eindeutig zugeordnete Handlungen gelernt wurden. Vorher bleibt das Gerät sicher im Beobachtungsmodus.</p>
<div id="actuator-detail" class="muted">Öffne bei einem beobachteten Gerät die Details.</div>
</section>
<section class="status-panel" id="status-section">
<div class="panel-title">
<div>
<h2>System & Cache</h2>
<p class="muted">Die Startansicht nutzt lokale Summaries und Cache-Daten. Home-Assistant-Discovery lädt erst bei Bedarf.</p>
</div>
<button class="secondary compact" onclick="loadStatus()">Status prüfen</button>
</div>
<div id="status">Prüfung läuft ...</div>
<div class="chips" id="status-chips"></div>
<div id="dashboard-stats" class="metric-grid"></div>
</section>
<section class="guide-panel" id="guide">
<details class="collapsible">
<summary><span>So gehst du vor</span></summary>
<div class="steps">
<div class="step">
<span class="step-number">1</span>
<h3>Aktor auswählen</h3>
<p><strong>Wo?</strong> Links im Feld „Gerät auswählen“.</p>
<p><strong>Was passiert?</strong> SillyHome ordnet Raum, Sensoren, Zustände und vorhandene Historie automatisch zu.</p>
</div>
<div class="step">
<span class="step-number">2</span>
<h3>Wie gewohnt bedienen</h3>
<p><strong>Wo?</strong> Weiterhin in Home Assistant, an Schaltern oder über deine bisherigen Bedienwege.</p>
<p><strong>Was passiert?</strong> SillyHome lernt deine Handlungen und zeigt Vorhersagen an, schaltet aber noch nicht selbst.</p>
</div>
<div class="step">
<span class="step-number">3</span>
<h3>Später freigeben</h3>
<p><strong>Wo?</strong> In den Details des ausgewählten Geräts, sobald genug Verhalten gelernt wurde.</p>
<p><strong>Was passiert?</strong> Erst dann darf SillyHome passende Vorhersagen automatisch ausführen. Die Freigabe kann jederzeit gestoppt werden.</p>
</div>
</div>
</details>
</section>
</main>
<script>
const escapeHtml = value => String(value ?? "")
@@ -206,8 +269,10 @@ let manualContextState = {options: [], selected: new Set()};
let cachedActuators = null;
let cachedEntities = null;
let cachedDiscovery = null;
let discoveryLoadPromise = null;
const ACTUATOR_RESULT_LIMIT = 50;
const STATUS_TIMEOUT_MS = 2500;
const STATUS_TIMEOUT_MS = 2000;
const DASHBOARD_TIMEOUT_MS = 4500;
function uniqueValues(values) {
return [...new Set(values.filter(Boolean))];
@@ -352,21 +417,27 @@ function optionGroups(entities, selectedIds = new Set()) {
}
async function loadOverview() {
const startedAt = performance.now();
const budget = document.getElementById("load-budget");
if (budget) budget.textContent = "Startdaten laden ...";
document.getElementById("configured-actuators").innerHTML = "<p class='muted'>Beobachtete Geräte werden geladen ...</p>";
try {
const dashboard = await api("v1/actuators/dashboard");
const dashboard = await apiWithTimeout("v1/actuators/dashboard", DASHBOARD_TIMEOUT_MS);
cachedActuators = dashboard.actuators || [];
cachedEntities = [];
renderDashboardStatus(dashboard);
renderConfiguredActuators();
if (budget) budget.textContent = `Bereit in ${Math.round(performance.now() - startedAt)} ms`;
} catch (error) {
document.getElementById("configured-actuators").innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
await loadSummaryData();
renderConfiguredActuators();
void loadStatus();
if (budget) budget.textContent = "Startdaten verzögert";
try {
await loadSummaryData();
renderConfiguredActuators();
} catch (_) {
document.getElementById("configured-actuators").innerHTML = "<div class='empty-state'>Startdaten sind gerade nicht verfügbar.</div>";
}
}
void loadActuatorDiscovery();
void loadActuatorSuggestions();
}
async function loadStatus() {
@@ -472,6 +543,21 @@ async function loadActuatorDiscovery() {
renderActuatorDiscovery();
}
async function ensureActuatorDiscovery() {
const select = document.getElementById("actuator-select");
if (cachedDiscovery) {
renderActuatorDiscovery();
return;
}
if (!discoveryLoadPromise) {
if (select) select.innerHTML = `<option value="">Geräteliste wird geladen ...</option>`;
discoveryLoadPromise = loadActuatorDiscovery().finally(() => {
discoveryLoadPromise = null;
});
}
await discoveryLoadPromise;
}
function renderActuatorDiscovery() {
const options = document.getElementById("actuator-options");
const select = document.getElementById("actuator-select");
@@ -607,7 +693,11 @@ async function configureActuator() {
|| document.getElementById("actuator-select").value.trim()
);
const result = document.getElementById("actuator-config-result");
if (!actuatorId) return;
if (!actuatorId) {
await ensureActuatorDiscovery();
result.textContent = "Wähle ein Gerät aus der geladenen Liste oder trage eine Entity-ID ein.";
return;
}
result.textContent = "Kontext wird automatisch analysiert ...";
try {
const record = await api("v1/actuators", {
@@ -685,12 +775,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
currentActuatorId = actuatorId;
const box = document.getElementById("actuator-detail");
try {
let record;
try {
record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}/related-automations/refresh`, {method: "POST"});
} catch (_) {
record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
}
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
await loadContextOptions(actuatorId);
const contexts = [
record.assignment.selected_numeric_entity_id,
@@ -820,6 +905,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
</div>
<h3>Passende Home-Assistant-Automationen</h3>
<p class="muted">Bei einer Übernahme pausiert SillyHome diese Automationen. Beim Stoppen können sie gezielt fortgesetzt werden.</p>
<button class="secondary compact" onclick="refreshRelatedAutomations('${escapeHtml(record.actuator_entity_id)}')">Automationen neu suchen</button>
${automationControls}
<h3>Welche Zusammenhänge automatisch verwendet werden</h3>
${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"}
@@ -972,6 +1058,17 @@ async function setRelatedAutomation(actuatorId, automationEntityId, enabled) {
}
}
async function refreshRelatedAutomations(actuatorId) {
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/related-automations/refresh`, {method: "POST"});
invalidateDashboardCache();
await loadConfiguredActuators();
await showActuator(actuatorId, "Passende Home-Assistant-Automationen neu geprüft.");
} catch (error) {
alert(error.message);
}
}
async function removeActuator(actuatorId) {
if (!confirm(`${actuatorId} aus SillyHome entfernen?`)) return;
try {

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "sillyhome-next"
version = "1.0.0"
version = "1.0.1"
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
requires-python = ">=3.11"
dependencies = [

View File

@@ -9,11 +9,14 @@ def test_dashboard_is_served_at_root() -> None:
assert response.status_code == 200
assert "SillyHome Next" in response.text
assert "Arbeitsdashboard für gelernte Home-Assistant-Bedienung" in response.text
assert "So gehst du vor" in response.text
assert "Gerät zum Lernen auswählen" in response.text
assert "Steuerung" in response.text
assert "Entitätsname oder Gerät aus Home Assistant" in response.text
assert "Oder aus Liste wählen" in response.text
assert "Liste durchsuchen" in response.text
assert "Geräteliste bei Bedarf laden" in response.text
assert "Vorschläge können Home Assistant stark abfragen" in response.text
assert "Wie gewohnt bedienen" in response.text
assert "Ohne deine spätere Freigabe wird nichts geschaltet" in response.text
assert "Du wählst keine Sensoren und erstellst keine Regeln" in response.text