Prepare SillyHome Next 1.0.0 dashboard and API rework
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled

This commit is contained in:
2026-06-17 01:13:43 +02:00
parent 4db4276b95
commit 7ba9807a4e
11 changed files with 412 additions and 44 deletions

View File

@@ -20,7 +20,7 @@ from app.actuators.models import (
)
from app.actuators.store import ActuatorStore
from app.config import Settings
from app.ha.discovery import DiscoveredEntity, EntityRole
from app.ha.discovery import DiscoveredEntity, EntityRole, discover_entities
from app.ha.history import EntityHistorySeries, NumericHistoryPoint
from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader
@@ -170,7 +170,7 @@ class ActuatorReconciliationService:
limit: int = _CONTEXT_SUGGESTION_LIMIT,
) -> list[HaEntitySummary]:
entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()}
discovered = {entity.entity_id: entity for entity in self._ha_reader.discover()}
discovered = {entity.entity_id: entity for entity in discover_entities(list(entities.values()))}
actuator = entities.get(actuator_entity_id)
if actuator is None:
raise KeyError("Aktuator-Konfiguration nicht gefunden.")
@@ -736,6 +736,11 @@ def _manual_context_role(
def _context_sort_group(entity: HaEntitySummary) -> str:
device_class = entity.device_class or ""
text = " ".join(
value.lower().replace("_", " ")
for value in [entity.entity_id, entity.friendly_name, entity.area_name, entity.device_name]
if value
)
if device_class in {"motion", "occupancy", "presence"}:
return "01_presence"
if device_class in {"illuminance"}:
@@ -744,10 +749,26 @@ def _context_sort_group(entity: HaEntitySummary) -> str:
return "03_opening"
if device_class in {"humidity", "moisture"}:
return "04_humidity"
if device_class in {"temperature"}:
return "05_temperature"
if any(token in text for token in {"pv", "solar", "akku", "batterie", "battery", "einspeisung"}):
return "06_pv_battery"
if device_class in {"power", "energy", "current", "voltage"}:
return "05_power"
return "07_power"
if entity.domain in {"weather"}:
return "08_weather"
if entity.domain in {"fan", "humidifier"}:
return "09_ventilation"
if entity.domain in {"climate"}:
return "10_heating"
if entity.domain in {"cover"}:
return "11_cover"
if entity.domain in {"light", "switch"}:
return "06_states"
return "12_states"
if entity.domain.startswith("input_"):
return "13_helper"
if entity.domain in {"person", "device_tracker"}:
return "14_people"
return f"20_{entity.domain}_{device_class}"
@@ -900,11 +921,12 @@ def _merge_manual_candidates(
def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]:
if context:
mapping = {
"climate": {"occupancy", "presence", "window"},
"cover": {"illuminance", "wind_speed"},
"climate": {"humidity", "illuminance", "occupancy", "presence", "temperature", "window"},
"cover": {"illuminance", "motion", "occupancy", "presence", "wind_speed"},
"fan": {"humidity", "moisture", "occupancy", "presence", "temperature"},
"humidifier": {"humidity", "moisture", "temperature"},
"light": {"door", "garage_door", "motion", "occupancy", "opening", "presence", "window"},
"light": {"door", "garage_door", "illuminance", "motion", "occupancy", "opening", "presence", "window"},
"media_player": {"occupancy", "presence"},
"switch": {"door", "garage_door", "motion", "occupancy", "opening", "presence", "window"},
}
return frozenset(

View File

@@ -14,7 +14,7 @@ from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine
from app.config import Settings
from app.dependencies import get_ha_reader
from app.ha.discovery import DiscoveredEntity, EntityRole
from app.ha.discovery import DiscoveredEntity, EntityRole, discover_entities
from app.ha.exceptions import HaClientError
from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader
@@ -79,6 +79,35 @@ class ActuatorSummary(BaseModel):
updated_at: str
class EntityCacheStatus(BaseModel):
available: bool
updated_at: str | None = None
entity_count: int = 0
class DashboardSystemStatus(BaseModel):
api_status: str = "ok"
websocket_status: str = "unavailable"
websocket_error: str | None = None
reconciliation_last_completed_at: str | None = None
configured_actuators: int = 0
trained_models: int = 0
review_required: int = 0
class DashboardDiscoveryGroup(BaseModel):
category: str
role: str
count: int
class DashboardOverview(BaseModel):
system: DashboardSystemStatus
cache: EntityCacheStatus
actuators: list[ActuatorSummary]
discovery_groups: list[DashboardDiscoveryGroup]
@router.get("/discovery", response_model=list[HaEntitySummary])
def discover_actuators(
request: Request,
@@ -92,7 +121,7 @@ def discover_actuators(
fresh_entities = list(ha_reader.read_entities())
_save_cached_entities(request, fresh_entities)
entities = {entity.entity_id: entity for entity in fresh_entities}
discovered = ha_reader.discover()
discovered = discover_entities(list(entities.values()))
actuator_ids = _deduplicate_actuator_ids(
[
(entity.entity_id, entity.category)
@@ -110,7 +139,7 @@ def suggest_actuators(
ha_reader: HaReader = Depends(get_ha_reader),
) -> list[ActuatorSuggestion]:
entities = {entity.entity_id: entity for entity in ha_reader.read_entities()}
discovered = {entity.entity_id: entity for entity in ha_reader.discover()}
discovered = {entity.entity_id: entity for entity in discover_entities(list(entities.values()))}
configured_ids = {record.actuator_entity_id for record in _service(request).list_configured()}
actuator_ids = _deduplicate_actuator_ids(
[
@@ -222,6 +251,53 @@ def list_configured_summary(request: Request) -> list[ActuatorSummary]:
]
@router.get("/dashboard", response_model=DashboardOverview)
def dashboard_overview(request: Request) -> DashboardOverview:
cache_payload = _load_entity_cache_payload(request)
raw_entities = cache_payload.get("entities", [])
if not isinstance(raw_entities, list):
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
reconciliation = _reconciliation_state_or_default(request)
ws_status = getattr(request.app.state, "ws_status", None)
actuators = list_configured_summary(request)
return DashboardOverview(
system=DashboardSystemStatus(
websocket_status=getattr(ws_status, "status", "unavailable"),
websocket_error=getattr(ws_status, "error", None),
reconciliation_last_completed_at=(
reconciliation.last_completed_at.isoformat()
if reconciliation.last_completed_at is not None
else None
),
configured_actuators=len(actuators),
trained_models=reconciliation.trained_models,
review_required=reconciliation.review_required,
),
cache=EntityCacheStatus(
available=bool(cached_entities),
updated_at=updated_at,
entity_count=len(cached_entities),
),
actuators=actuators,
discovery_groups=[
DashboardDiscoveryGroup(category=category, role=role, count=count)
for (category, role), count in sorted(group_counts.items())
],
)
@router.get("", response_model=list[ActuatorRecord])
def list_configured(request: Request) -> list[ActuatorRecord]:
return _service(request).list_configured()
@@ -412,6 +488,16 @@ def _behavior(request: Request) -> BehaviorEngine:
return engine
def _reconciliation_state_or_default(request: Request) -> ReconciliationState:
store = getattr(request.app.state, "actuator_store", None)
if not isinstance(store, ActuatorStore):
return ReconciliationState()
try:
return store.load_reconciliation_state()
except ValueError:
return ReconciliationState(last_summary="Reconciliation-Status ist unlesbar.")
def _entity_cache_path(request: Request) -> Path:
store = getattr(request.app.state, "actuator_store", None)
settings = getattr(request.app.state, "settings", None)
@@ -425,17 +511,27 @@ def _entity_cache_path(request: Request) -> Path:
def _load_cached_entities(request: Request) -> list[HaEntitySummary]:
path = _entity_cache_path(request)
if not path.exists():
payload = _load_entity_cache_payload(request)
raw_entities = payload.get("entities", [])
if not isinstance(raw_entities, list):
return []
try:
payload = json.loads(path.read_text(encoding="utf-8"))
raw_entities = payload.get("entities", [])
return [HaEntitySummary.model_validate(entity) for entity in raw_entities]
except (OSError, TypeError, ValueError):
except ValueError:
return []
def _load_entity_cache_payload(request: Request) -> dict[str, object]:
path = _entity_cache_path(request)
if not path.exists():
return {}
try:
payload = json.loads(path.read_text(encoding="utf-8"))
return payload if isinstance(payload, dict) else {}
except (OSError, TypeError, ValueError):
return {}
def _save_cached_entities(request: Request, entities: list[HaEntitySummary]) -> None:
path = _entity_cache_path(request)
path.parent.mkdir(parents=True, exist_ok=True)

View File

@@ -214,9 +214,12 @@ def _result(
def _actuator_category(entity: HaEntitySummary) -> str:
text = _entity_text(entity)
if entity.domain == "light":
return "light"
if entity.domain == "switch":
if any(token in text for token in {"steckdose", "socket", "plug", "outlet", "shelly"}):
return "socket"
return "switch_socket"
if entity.domain == "button" or entity.domain == "input_button":
return "button"
@@ -239,13 +242,31 @@ def _actuator_category(entity: HaEntitySummary) -> str:
def _measurement_category(entity: HaEntitySummary) -> str:
device_class = entity.device_class or ""
text = _entity_text(entity)
if any(
token in text
for token in {
"pv",
"solar",
"photovoltaik",
"akku",
"batterie",
"battery",
"einspeisung",
"wechselrichter",
"inverter",
}
):
return "pv_battery_grid"
if entity.domain == "weather":
return "weather"
if device_class == "illuminance":
return "brightness"
if device_class == "temperature":
return "temperature"
if device_class in {"humidity", "moisture"}:
return "humidity"
if device_class in {"power", "energy", "current", "voltage"}:
if device_class in {"power", "energy", "current", "voltage", "apparent_power"}:
return "energy_power"
if device_class in {"battery", "signal_strength"}:
return "diagnostic"
@@ -260,12 +281,42 @@ def _binary_category(entity: HaEntitySummary) -> str:
return "opening"
if device_class in {"smoke", "safety", "problem"}:
return "safety"
if device_class in {"lock"}:
return "lock_state"
return "binary"
def _context_category(entity: HaEntitySummary) -> str:
text = _entity_text(entity)
if entity.domain.startswith("input_"):
return "helper"
if entity.domain in {"person", "device_tracker", "zone"}:
return "presence_location"
if entity.domain == "weather":
return "weather"
if entity.domain in {"light"}:
return "light_state"
if entity.domain in {"switch"}:
if any(token in text for token in {"steckdose", "socket", "plug", "outlet", "shelly"}):
return "socket_state"
return "switch_state"
if entity.domain in {"climate"}:
return "heating_state"
if entity.domain in {"fan", "humidifier"}:
return "ventilation_state"
if entity.domain in {"cover"}:
return "cover_state"
return entity.domain
def _entity_text(entity: HaEntitySummary) -> str:
return " ".join(
value.lower().replace("_", " ")
for value in [
entity.entity_id,
entity.friendly_name,
entity.area_name,
entity.device_name,
]
if value
)

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="0.7.21",
version="1.0.0",
lifespan=lifespan,
)
app.state.settings = load_settings()

View File

@@ -5,18 +5,18 @@
<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: #10151c; color: #eaf1f8; scroll-behavior:smooth; }
: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,#142b3a,#193f36); }
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,.85fr) minmax(0,1.35fr); gap: 12px; padding: 12px; max-width:1380px; margin:0 auto; }
section { background: #18212b; border: 1px solid #2d3a47; border-radius: 8px; padding: 12px; min-width:0; }
section:target { outline:2px solid #66dfa9; outline-offset:2px; }
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(16,21,28,.94); border-bottom:1px solid #2d3a47; backdrop-filter:blur(8px); }
.quick-nav a { flex:0 0 auto; padding:10px 12px; border-radius:999px; background:#22303c; border:1px solid #31404d; color:#eaf1f8; text-decoration:none; font-weight:700; font-size:.92rem; }
.quick-nav a.primary { background:#23715b; }
.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); }
details.collapsible > summary,
.manual-context > summary,
.group-panel > summary { cursor:pointer; font-weight:800; color:#eaf1f8; }
@@ -31,39 +31,39 @@
.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:#111a23; border:1px solid #31404d; border-radius:8px; padding:10px; }
.step-number { display:inline-grid; place-items:center; width:28px; height:28px; border-radius:50%; background:#23715b; font-weight:700; margin-bottom:8px; }
.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 p { margin:5px 0; }
.ok { color: #66dfa9; }
.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: #101820; color: #fff; font:inherit; min-width:0; }
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; }
select[multiple] { min-height:150px; }
button { min-height:44px; margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; }
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; }
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 #66dfa9; padding-left: 10px; }
.notice { border-left: 4px solid var(--accent); 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:#22303c; border:1px solid #31404d; font-size:.85rem; }
.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:#111a23; border:1px solid #31404d; border-radius:8px; padding:10px; min-width:0; }
.actuator-card.selected { border-color:#66dfa9; box-shadow:0 0 0 1px rgba(102,223,169,.3); }
.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); }
.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; }
.metric { background:#18212b; border:1px solid #2d3a47; border-radius:8px; padding:8px; min-width:0; }
.metric { background:var(--panel-soft); border:1px solid var(--border); border-radius:8px; padding:8px; min-width:0; }
.metric strong { display:block; margin-bottom:4px; color:#cfe0ec; font-size:.84rem; }
.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:#111a23; border:1px solid #31404d; border-radius:8px; padding:10px; }
.manual-context { margin-top:12px; background:#12161c; 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; }
@@ -130,11 +130,12 @@
</section>
<section id="status-section">
<h2>Systemstatus</h2>
<p class="muted">Zeigt, ob Verbindung, Lernsystem und automatische Prüfungen funktionieren. Hier musst du normalerweise nichts einstellen.</p>
<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>
<button class="secondary" onclick="loadOverview()">Status aktualisieren</button>
<div id="dashboard-stats" class="metric-grid"></div>
<button class="secondary" onclick="loadOverview()">Dashboard aktualisieren</button>
</section>
<section id="choose">
@@ -352,9 +353,18 @@ function optionGroups(entities, selectedIds = new Set()) {
async function loadOverview() {
document.getElementById("configured-actuators").innerHTML = "<p class='muted'>Beobachtete Geräte werden geladen ...</p>";
await loadSummaryData();
renderConfiguredActuators();
void loadStatus();
try {
const dashboard = await api("v1/actuators/dashboard");
cachedActuators = dashboard.actuators || [];
cachedEntities = [];
renderDashboardStatus(dashboard);
renderConfiguredActuators();
} catch (error) {
document.getElementById("configured-actuators").innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
await loadSummaryData();
renderConfiguredActuators();
void loadStatus();
}
void loadActuatorDiscovery();
void loadActuatorSuggestions();
}
@@ -392,6 +402,36 @@ async function loadStatus() {
}
}
function renderDashboardStatus(dashboard) {
const status = document.getElementById("status");
const chips = document.getElementById("status-chips");
const stats = document.getElementById("dashboard-stats");
const system = dashboard.system || {};
const cache = dashboard.cache || {};
const discoveryGroups = dashboard.discovery_groups || [];
const cacheLabel = cache.available
? `Cache aktuell mit ${cache.entity_count} Entities`
: "Cache wird nach Discovery aufgebaut";
status.innerHTML = `
<p class="${system.websocket_status === "connected" ? "ok" : "warn"}">
Dashboard bereit. WebSocket: ${escapeHtml(system.websocket_status || "unbekannt")}
</p>
<p class="muted">Letzte automatische Prüfung: ${escapeHtml(system.reconciliation_last_completed_at || "noch nicht abgeschlossen")}</p>
`;
chips.innerHTML = [
`<span class="chip">API: ${escapeHtml(system.api_status || "ok")}</span>`,
`<span class="chip">Cache: ${escapeHtml(cacheLabel)}</span>`,
`<span class="chip">Aktoren: ${escapeHtml(system.configured_actuators ?? 0)}</span>`,
`<span class="chip">Lernbereit: ${escapeHtml(system.trained_models ?? 0)}</span>`,
`<span class="chip">Prüfen: ${escapeHtml(system.review_required ?? 0)}</span>`,
].join("");
stats.innerHTML = [
`<div class="metric"><strong>Geladene Startdaten</strong>${escapeHtml((dashboard.actuators || []).length)} Geräte</div>`,
`<div class="metric"><strong>Discovery-Gruppen</strong>${escapeHtml(discoveryGroups.length)} Kategorien</div>`,
`<div class="metric"><strong>Cache-Zeitpunkt</strong>${escapeHtml(cache.updated_at || "noch offen")}</div>`,
].join("");
}
async function loadSummaryData() {
const actuators = await api("v1/actuators/summary");
cachedActuators = actuators;