Compare commits

...

3 Commits

Author SHA1 Message Date
387e027fe2 Use lightweight actuator dashboard summaries
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 00:09:35 +02:00
f8bee92e64 Optimize dashboard categories and context loading
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 00:02:19 +02:00
9ddb065f62 Speed up HA event processing
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-16 13:58:58 +02:00
11 changed files with 316 additions and 46 deletions

View File

@@ -1,5 +1,27 @@
# Changelog # Changelog
## 0.7.19 - 2026-06-17
- Dashboard-Übersicht nutzt einen leichten `/v1/actuators/summary`-Endpunkt
statt voller Lernmuster und kompletter HA-Entityliste.
- Nach Aktionen werden Dashboard-Caches gezielt invalidiert, damit keine
stale oder doppelt geladenen Einträge entstehen.
## 0.7.18 - 2026-06-16
- Dashboard lädt Aktoren, Entities und Discovery nur noch einmal pro Refresh und
rendert daraus Auswahl und Übersicht ohne doppelte API-Ladewege.
- Manuelle Kontext-Evidenz wird dedupliziert, damit Hinweise wie
"Manuell vom Nutzer als relevant festgelegt" nicht mehrfach erscheinen.
- Kontextauswahl ist vollständiger: Feuchte, Wetter, Licht-/Schalterzustände,
Bewegungs-/Tür-/Präsenzmelder, PV/Akku/Einspeisung und Helper werden sauberer
kategorisiert und per Suche/Kategorie erreichbar.
- Domainspezifische Zuordnung geschärft: Lüftungen bevorzugen Feuchte/Temperatur,
Lichter Helligkeit/Bewegung/Tür/Präsenz, Heizungen Temperatur/Anwesenheit/Wetter.
## 0.7.17 - 2026-06-16
- WebSocket-Eventpfad ist schneller: irrelevante HA-State-Changes werden vor
dem teuren State-Cache-Listenbau verworfen.
- WebSocket nutzt Keepalive und reconnectet nach Abbrüchen nach 1s statt 5s.
## 0.7.16 - 2026-06-16 ## 0.7.16 - 2026-06-16
- Beobachtete Aktoren werden in der Übersicht nach Raum oder Typ gruppiert und - Beobachtete Aktoren werden in der Übersicht nach Raum oder Typ gruppiert und
mit Friendly Name angezeigt. mit Friendly Name angezeigt.

View File

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

View File

@@ -72,29 +72,42 @@ _MANUAL_CONTEXT_DOMAINS = frozenset({
"device_tracker", "device_tracker",
"fan", "fan",
"humidifier", "humidifier",
"input_boolean",
"input_number",
"input_select",
"light", "light",
"media_player",
"person", "person",
"remote",
"scene",
"sensor", "sensor",
"sun",
"switch", "switch",
"weather", "weather",
}) })
_CONTEXT_SUGGESTION_LIMIT = 120 _CONTEXT_SUGGESTION_LIMIT = 500
_OUTDOOR_TOKENS = frozenset({"aussen", "außen", "outdoor", "garten", "terrasse", "balkon"}) _OUTDOOR_TOKENS = frozenset({"aussen", "außen", "outdoor", "garten", "terrasse", "balkon"})
_DIAGNOSTIC_TOKENS = frozenset({ _DIAGNOSTIC_TOKENS = frozenset({
"basic", "basic",
"battery", "battery",
"bytes",
"connect", "connect",
"count", "count",
"data",
"diagnostic", "diagnostic",
"firmware", "firmware",
"gesehen", "gesehen",
"heat", "heat",
"inbytes",
"interface",
"last", "last",
"linkquality", "linkquality",
"knoten", "knoten",
"knotens", "knotens",
"mqtt", "mqtt",
"node", "node",
"outbytes",
"pfsense",
"reason", "reason",
"restart", "restart",
"rssi", "rssi",
@@ -105,6 +118,7 @@ _DIAGNOSTIC_TOKENS = frozenset({
"overheating", "overheating",
"overload", "overload",
"uptime", "uptime",
"vpn",
"uberhitzung", "uberhitzung",
"ueberhitzung", "ueberhitzung",
"ueberlast", "ueberlast",
@@ -175,13 +189,10 @@ class ActuatorReconciliationService:
selected = entity.entity_id in selected_ids selected = entity.entity_id in selected_ids
if selected: if selected:
score = max(score, 1.0) score = max(score, 1.0)
if not selected and ( if not selected and _is_diagnostic_context(entity):
_is_diagnostic_context(entity)
or not _has_context_relationship(actuator, entity)
):
continue
if not selected and score < 0.1:
continue continue
if not selected and not _has_context_relationship(actuator, entity):
score = max(score, 0.01)
ranked.append((score, _context_sort_group(entity), entity)) ranked.append((score, _context_sort_group(entity), entity))
ranked.sort( ranked.sort(
key=lambda item: ( key=lambda item: (
@@ -849,12 +860,17 @@ def _merge_manual_candidates(
for entity_id in selected_entity_ids: for entity_id in selected_entity_ids:
existing = by_id.get(entity_id) existing = by_id.get(entity_id)
if existing is not None: if existing is not None:
evidence = [
item
for item in existing.evidence
if item != "Manuell vom Nutzer als relevant festgelegt."
]
by_id[entity_id] = existing.model_copy( by_id[entity_id] = existing.model_copy(
update={ update={
"auto_accepted": True, "auto_accepted": True,
"confidence": 1.0, "confidence": 1.0,
"evidence": [ "evidence": [
*existing.evidence, *evidence,
"Manuell vom Nutzer als relevant festgelegt.", "Manuell vom Nutzer als relevant festgelegt.",
], ],
} }
@@ -883,13 +899,28 @@ def _merge_manual_candidates(
def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]: def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]:
if context: if context:
return frozenset({"door", "garage_door", "motion", "occupancy", "opening", "presence"}) mapping = {
"climate": {"occupancy", "presence", "window"},
"cover": {"illuminance", "wind_speed"},
"fan": {"humidity", "moisture", "occupancy", "presence", "temperature"},
"humidifier": {"humidity", "moisture", "temperature"},
"light": {"door", "garage_door", "motion", "occupancy", "opening", "presence", "window"},
"switch": {"door", "garage_door", "motion", "occupancy", "opening", "presence", "window"},
}
return frozenset(
mapping.get(
domain,
{"door", "garage_door", "motion", "occupancy", "opening", "presence"},
)
)
mapping = { mapping = {
"climate": {"temperature", "humidity", "power"}, "climate": {"temperature", "humidity"},
"cover": {"illuminance", "temperature", "wind_speed"}, "cover": {"illuminance", "temperature", "wind_speed"},
"fan": {"temperature", "humidity", "power"}, "fan": {"temperature", "humidity", "moisture"},
"humidifier": {"humidity", "temperature", "power"}, "humidifier": {"humidity", "moisture", "temperature"},
"light": {"illuminance", "power", "energy"}, "light": {"illuminance"},
"media_player": {"power", "energy"},
"remote": {"battery"},
"switch": {"power", "energy", "current"}, "switch": {"power", "energy", "current"},
"valve": {"temperature", "pressure", "humidity"}, "valve": {"temperature", "pressure", "humidity"},
} }

View File

@@ -55,6 +55,21 @@ class ActuatorSuggestion(BaseModel):
likely_context_count: int = 0 likely_context_count: int = 0
class ActuatorSummary(BaseModel):
actuator_entity_id: str
domain: str
enabled: bool
behavior_mode: str
behavior_status: str
lifecycle_status: str
activation_ready: bool
activation_reason: str
sample_count: int
prediction_target_state: str | None = None
prediction_confidence: float | None = None
updated_at: str
@router.get("/discovery", response_model=list[HaEntitySummary]) @router.get("/discovery", response_model=list[HaEntitySummary])
def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaEntitySummary]: def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaEntitySummary]:
entities = {entity.entity_id: entity for entity in ha_reader.read_entities()} entities = {entity.entity_id: entity for entity in ha_reader.read_entities()}
@@ -143,6 +158,35 @@ def context_options(
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("/summary", response_model=list[ActuatorSummary])
def list_configured_summary(request: Request) -> list[ActuatorSummary]:
return [
ActuatorSummary(
actuator_entity_id=record.actuator_entity_id,
domain=record.actuator_entity_id.split(".", 1)[0],
enabled=record.enabled,
behavior_mode=record.behavior.mode.value,
behavior_status=record.behavior.status.value,
lifecycle_status=record.lifecycle.status.value,
activation_ready=record.behavior.activation_ready,
activation_reason=record.behavior.activation_reason,
sample_count=record.behavior.sample_count,
prediction_target_state=(
record.behavior.prediction.target_state
if record.behavior.prediction is not None
else None
),
prediction_confidence=(
record.behavior.prediction.confidence
if record.behavior.prediction is not None
else None
),
updated_at=record.updated_at.isoformat(),
)
for record in _service(request).list_configured()
]
@router.get("", response_model=list[ActuatorRecord]) @router.get("", response_model=list[ActuatorRecord])
def list_configured(request: Request) -> list[ActuatorRecord]: def list_configured(request: Request) -> list[ActuatorRecord]:
return _service(request).list_configured() return _service(request).list_configured()

View File

@@ -900,6 +900,8 @@ def predict_behavior(
def service_for_state(domain: str, target_state: str) -> str | None: def service_for_state(domain: str, target_state: str) -> str | None:
if domain in {"fan", "humidifier", "light", "media_player", "remote", "switch"}: if domain in {"fan", "humidifier", "light", "media_player", "remote", "switch"}:
return {"on": "turn_on", "off": "turn_off"}.get(target_state) return {"on": "turn_on", "off": "turn_off"}.get(target_state)
if domain == "scene":
return "turn_on" if target_state == "on" else None
if domain == "cover": if domain == "cover":
return {"open": "open_cover", "closed": "close_cover"}.get(target_state) return {"open": "open_cover", "closed": "close_cover"}.get(target_state)
return None return None

View File

@@ -95,6 +95,7 @@ _ACTUATOR_DOMAINS = frozenset({
"media_player", "media_player",
"number", "number",
"remote", "remote",
"scene",
"siren", "siren",
"switch", "switch",
"valve", "valve",
@@ -229,6 +230,8 @@ def _actuator_category(entity: HaEntitySummary) -> str:
return "fan" return "fan"
if entity.domain in {"media_player", "remote"}: if entity.domain in {"media_player", "remote"}:
return "media_tv" return "media_tv"
if entity.domain == "scene":
return "scene"
if entity.domain in {"input_boolean", "number"}: if entity.domain in {"input_boolean", "number"}:
return "helper" return "helper"
return entity.domain return entity.domain

View File

@@ -105,7 +105,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app = FastAPI( app = FastAPI(
title="SillyHome Next API", title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="0.7.16", version="0.7.19",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()
@@ -211,7 +211,11 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
if ws_status is not None: if ws_status is not None:
ws_status.status = "connecting" ws_status.status = "connecting"
try: try:
async with websockets.connect(ws_url, ping_interval=None) as websocket: async with websockets.connect(
ws_url,
ping_interval=20,
ping_timeout=10,
) as websocket:
auth_required_msg = await websocket.recv() auth_required_msg = await websocket.recv()
auth_required_data = json.loads(auth_required_msg) auth_required_data = json.loads(auth_required_msg)
if auth_required_data.get("type") != "auth_required": if auth_required_data.get("type") != "auth_required":
@@ -263,6 +267,8 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
continue continue
new_state = event_data.get("new_state") new_state = event_data.get("new_state")
_update_ha_state_cache(state_cache, entity_id, new_state) _update_ha_state_cache(state_cache, entity_id, new_state)
if not _is_relevant_state_change(store, str(entity_id)):
continue
# Prüfe, ob Entity ein Aktor oder relevanter Kontext ist # Prüfe, ob Entity ein Aktor oder relevanter Kontext ist
# Sofortige Vorhersage für betroffene Aktoren auslösen # Sofortige Vorhersage für betroffene Aktoren auslösen
await asyncio.to_thread( await asyncio.to_thread(
@@ -280,17 +286,17 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
websockets.exceptions.InvalidStatus, websockets.exceptions.InvalidStatus,
OSError, OSError,
) as exc: ) as exc:
logger.warning("WebSocket-Verbindung unterbrochen: %s. Wiederholung in 5s...", exc) logger.warning("WebSocket-Verbindung unterbrochen: %s. Wiederholung in 1s...", exc)
if ws_status is not None: if ws_status is not None:
ws_status.status = "reconnecting" ws_status.status = "reconnecting"
ws_status.error = str(exc) ws_status.error = str(exc)
await asyncio.sleep(5) await asyncio.sleep(1)
except Exception as exc: except Exception as exc:
logger.exception("Unerwarteter Fehler im Event-Listener: %s", exc) logger.exception("Unerwarteter Fehler im Event-Listener: %s", exc)
if ws_status is not None: if ws_status is not None:
ws_status.status = "error" ws_status.status = "error"
ws_status.error = str(exc) ws_status.error = str(exc)
await asyncio.sleep(5) await asyncio.sleep(1)
# Fallback: periodische Vorhersage falls Event-Stream ausfällt # Fallback: periodische Vorhersage falls Event-Stream ausfällt
@@ -341,6 +347,17 @@ def _update_ha_state_cache(
) )
def _is_relevant_state_change(store: ActuatorStore, entity_id: str) -> bool:
for record in store.list():
if record.actuator_entity_id == entity_id:
return True
if record.assignment.selected_numeric_entity_id == entity_id:
return True
if entity_id in record.assignment.selected_context_entity_ids:
return True
return False
def _ha_entity_from_event( def _ha_entity_from_event(
entity_id: str, entity_id: str,
new_state: dict[str, object], new_state: dict[str, object],

View File

@@ -144,6 +144,7 @@
<option value="humidifier">Befeuchter / Entfeuchter</option> <option value="humidifier">Befeuchter / Entfeuchter</option>
<option value="media_player">TV / Medien</option> <option value="media_player">TV / Medien</option>
<option value="remote">Fernbedienungen</option> <option value="remote">Fernbedienungen</option>
<option value="scene">Szenen</option>
<option value="number">Numerische Helper</option> <option value="number">Numerische Helper</option>
<option value="valve">Ventile</option> <option value="valve">Ventile</option>
</select> </select>
@@ -185,8 +186,21 @@ let currentActuatorId = null;
let actuatorChoices = []; let actuatorChoices = [];
let contextOptions = []; let contextOptions = [];
let manualContextState = {options: [], selected: new Set()}; let manualContextState = {options: [], selected: new Set()};
let cachedActuators = null;
let cachedEntities = null;
let cachedDiscovery = null;
const ACTUATOR_RESULT_LIMIT = 50; const ACTUATOR_RESULT_LIMIT = 50;
function uniqueValues(values) {
return [...new Set(values.filter(Boolean))];
}
function invalidateDashboardCache() {
cachedActuators = null;
cachedEntities = null;
cachedDiscovery = null;
}
async function api(path, options = {}) { async function api(path, options = {}) {
const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options}); const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options});
const body = response.status === 204 ? null : await response.json().catch(() => ({})); const body = response.status === 204 ? null : await response.json().catch(() => ({}));
@@ -195,7 +209,9 @@ async function api(path, options = {}) {
} }
function lifecycleLabel(record) { function lifecycleLabel(record) {
if (record.behavior.status === "trained") return "Kontext erkannt"; const behaviorStatus = record.behavior_status || record.behavior?.status;
const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status;
if (behaviorStatus === "trained") return "Kontext erkannt";
const labels = { const labels = {
trained: "lernt", trained: "lernt",
pending_history: "sammelt Historie", pending_history: "sammelt Historie",
@@ -204,26 +220,32 @@ function lifecycleLabel(record) {
archived: "wartet auf Kontext", archived: "wartet auf Kontext",
orphaned: "Aktor nicht gefunden", orphaned: "Aktor nicht gefunden",
}; };
return labels[record.lifecycle.status] || record.lifecycle.status; return labels[lifecycleStatus] || lifecycleStatus;
} }
function statusClass(record) { function statusClass(record) {
if (record.behavior.status === "trained") return "ok"; const behaviorStatus = record.behavior_status || record.behavior?.status;
if (record.lifecycle.status === "trained") return "ok"; const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status;
if (["pending_history", "pending_assignment", "archived"].includes(record.lifecycle.status)) return "warn"; if (behaviorStatus === "trained") return "ok";
if (lifecycleStatus === "trained") return "ok";
if (["pending_history", "pending_assignment", "archived"].includes(lifecycleStatus)) return "warn";
return "bad"; return "bad";
} }
function behaviorLabel(record) { function behaviorLabel(record) {
if (record.behavior.mode === "active") return "aktiv freigegeben"; const mode = record.behavior_mode || record.behavior?.mode;
if (record.behavior.status === "trained") return "Shadow-Vorhersage"; const status = record.behavior_status || record.behavior?.status;
if (record.behavior.status === "blocked") return "Lernen blockiert"; if (mode === "active") return "aktiv freigegeben";
if (status === "trained") return "Shadow-Vorhersage";
if (status === "blocked") return "Lernen blockiert";
return "sammelt Handlungen"; return "sammelt Handlungen";
} }
function predictionLabel(record) { function predictionLabel(record) {
return record.behavior.prediction const target = record.prediction_target_state || record.behavior?.prediction?.target_state;
? `${record.behavior.prediction.target_state} (${Math.round(record.behavior.prediction.confidence * 100)} %)` const confidence = record.prediction_confidence ?? record.behavior?.prediction?.confidence;
return target
? `${target} (${Math.round(confidence * 100)} %)`
: "Keine fällige Aktion"; : "Keine fällige Aktion";
} }
@@ -251,14 +273,33 @@ function matchesSearch(entity, query) {
function categoryForEntity(entity) { function categoryForEntity(entity) {
const cls = entity.device_class || ""; const cls = entity.device_class || "";
const text = normalizedSearch([
entity.entity_id,
entity.friendly_name,
entity.area_name,
entity.device_name,
].filter(Boolean).join(" "));
if (["pv", "solar", "akku", "batterie", "battery", "einspeisung", "wechselrichter"].some(token => text.includes(token))) {
return "PV / Akku / Einspeisung";
}
if (entity.domain === "fan") return "Lüftung / Ventilatoren";
if (entity.domain === "climate") return "Heizung / Klima";
if (entity.domain === "weather") return "Wetter";
if (entity.domain === "person" || entity.domain === "device_tracker") return "Anwesenheit / Personen";
if (entity.domain === "cover") return "Rollläden / Cover";
if (entity.domain === "light") return "Lichtzustände"; if (entity.domain === "light") return "Lichtzustände";
if (entity.domain === "switch") return "Schalter / Helper"; if (entity.domain === "switch") return "Schalter / Steckdosen";
if (entity.domain.startsWith("input_")) return "Helper";
if (entity.domain === "scene") return "Szenen";
if (entity.domain === "media_player" || entity.domain === "remote") return "TV / Medien";
if (["motion", "occupancy", "presence"].includes(cls)) return "PIR / Präsenz"; if (["motion", "occupancy", "presence"].includes(cls)) return "PIR / Präsenz";
if (["illuminance"].includes(cls)) return "Helligkeit"; if (["illuminance"].includes(cls)) return "Helligkeit";
if (["door", "garage_door", "opening", "window"].includes(cls)) return "Tür / Fenster"; if (["door", "garage_door", "opening", "window"].includes(cls)) return "Tür / Fenster";
if (["smoke", "safety", "problem"].includes(cls)) return "Sicherheit / Diagnose";
if (["humidity", "moisture"].includes(cls)) return "Luftfeuchtigkeit"; if (["humidity", "moisture"].includes(cls)) return "Luftfeuchtigkeit";
if (["temperature"].includes(cls)) return "Temperatur"; if (["temperature"].includes(cls)) return "Temperatur";
if (["power", "energy", "current", "voltage"].includes(cls)) return "Strom / Energie"; if (["power", "energy", "current", "voltage"].includes(cls)) return "Strom / Energie";
if (["battery", "signal_strength"].includes(cls)) return "Batterie / Signal";
if (entity.domain === "binary_sensor") return "Binäre Sensoren"; if (entity.domain === "binary_sensor") return "Binäre Sensoren";
if (entity.domain === "sensor") return "Weitere Messsensoren"; if (entity.domain === "sensor") return "Weitere Messsensoren";
return "Weitere Zustände"; return "Weitere Zustände";
@@ -305,18 +346,36 @@ async function loadOverview() {
status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`; status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
chips.innerHTML = ""; chips.innerHTML = "";
} }
await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators()]); await loadDashboardData();
renderActuatorDiscovery();
renderConfiguredActuators();
void loadActuatorSuggestions(); void loadActuatorSuggestions();
} }
async function loadDashboardData() {
const [actuators, entities, discovery] = await Promise.all([
api("v1/actuators/summary"),
Promise.resolve([]),
api("v1/actuators/discovery"),
]);
cachedActuators = actuators;
cachedEntities = entities;
cachedDiscovery = discovery;
}
async function loadActuatorDiscovery() { async function loadActuatorDiscovery() {
if (!cachedActuators || !cachedDiscovery) {
await loadDashboardData();
}
renderActuatorDiscovery();
}
function renderActuatorDiscovery() {
const options = document.getElementById("actuator-options"); const options = document.getElementById("actuator-options");
const select = document.getElementById("actuator-select"); const select = document.getElementById("actuator-select");
try { try {
const [available, configured] = await Promise.all([ const available = cachedDiscovery || [];
api("v1/actuators/discovery"), const configured = cachedActuators || [];
api("v1/actuators"),
]);
const configuredIds = new Set(configured.map(record => record.actuator_entity_id)); const configuredIds = new Set(configured.map(record => record.actuator_entity_id));
actuatorChoices = available.filter(entity => !configuredIds.has(entity.entity_id)); actuatorChoices = available.filter(entity => !configuredIds.has(entity.entity_id));
options.innerHTML = actuatorChoices.slice(0, 120).map(entity => options.innerHTML = actuatorChoices.slice(0, 120).map(entity =>
@@ -366,6 +425,7 @@ function actuatorGroupLabel(domain) {
media_player: "TV / Medien", media_player: "TV / Medien",
number: "Numerische Helper", number: "Numerische Helper",
remote: "Fernbedienungen", remote: "Fernbedienungen",
scene: "Szenen",
switch: "Schalter / Steckdosen", switch: "Schalter / Steckdosen",
cover: "Rollläden / Cover", cover: "Rollläden / Cover",
fan: "Lüftung / Ventilatoren", fan: "Lüftung / Ventilatoren",
@@ -463,12 +523,17 @@ async function configureActuator() {
} }
async function loadConfiguredActuators() { async function loadConfiguredActuators() {
if (!cachedActuators || !cachedEntities) {
await loadDashboardData();
}
renderConfiguredActuators();
}
function renderConfiguredActuators() {
const box = document.getElementById("configured-actuators"); const box = document.getElementById("configured-actuators");
try { try {
const [rows, entities] = await Promise.all([ const rows = cachedActuators || [];
api("v1/actuators"), const entities = cachedEntities || [];
api("v1/entities"),
]);
const entityMap = new Map(entities.map(entity => [entity.entity_id, entity])); const entityMap = new Map(entities.map(entity => [entity.entity_id, entity]));
const groups = new Map(); const groups = new Map();
for (const record of rows) { for (const record of rows) {
@@ -493,15 +558,15 @@ async function loadConfiguredActuators() {
<span class="chip">${escapeHtml(lifecycleLabel(record))}</span> <span class="chip">${escapeHtml(lifecycleLabel(record))}</span>
</div> </div>
<div class="metric-grid"> <div class="metric-grid">
<div class="metric"><strong>Freigabe</strong><span class="${record.behavior.activation_ready ? "ok" : "warn"}">${escapeHtml(record.behavior.activation_ready ? "bereit" : record.behavior.activation_reason)}</span></div> <div class="metric"><strong>Freigabe</strong><span class="${record.activation_ready ? "ok" : "warn"}">${escapeHtml(record.activation_ready ? "bereit" : record.activation_reason)}</span></div>
<div class="metric"><strong>Handlungen</strong>${record.behavior.sample_count}</div> <div class="metric"><strong>Handlungen</strong>${record.sample_count}</div>
<div class="metric"><strong>Vorhersage</strong>${escapeHtml(predictionLabel(record))}</div> <div class="metric"><strong>Vorhersage</strong>${escapeHtml(predictionLabel(record))}</div>
</div> </div>
<div class="actions"> <div class="actions">
<button onclick="showActuator('${escapeHtml(record.actuator_entity_id)}')">Details öffnen</button> <button onclick="showActuator('${escapeHtml(record.actuator_entity_id)}')">Details öffnen</button>
${record.behavior.mode === "active" ${record.behavior_mode === "active"
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">Stoppen + HA-Automationen fortsetzen</button>` ? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">Stoppen + HA-Automationen fortsetzen</button>`
: record.behavior.activation_ready : record.activation_ready
? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true, true, false)">SillyHome übernehmen lassen</button>` ? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true, true, false)">SillyHome übernehmen lassen</button>`
: ""} : ""}
<button class="danger" onclick="removeActuator('${escapeHtml(record.actuator_entity_id)}')">Entfernen</button> <button class="danger" onclick="removeActuator('${escapeHtml(record.actuator_entity_id)}')">Entfernen</button>
@@ -532,7 +597,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
].filter(Boolean); ].filter(Boolean);
const evidence = [...record.numeric_candidates, ...record.context_candidates] const evidence = [...record.numeric_candidates, ...record.context_candidates]
.filter(candidate => contexts.includes(candidate.entity_id)) .filter(candidate => contexts.includes(candidate.entity_id))
.map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${candidate.evidence.map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`) .map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${uniqueValues(candidate.evidence).map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`)
.join(""); .join("");
const currentContextControls = contexts.length const currentContextControls = contexts.length
? `<ul>${contexts.map(entityId => ` ? `<ul>${contexts.map(entityId => `
@@ -686,6 +751,7 @@ async function saveManualAssignment(actuatorId) {
note: "Manuell im Dashboard gesetzt", note: "Manuell im Dashboard gesetzt",
}), }),
}); });
invalidateDashboardCache();
await loadConfiguredActuators(); await loadConfiguredActuators();
await showActuator(actuatorId, "Manuelle Kontext-Auswahl gespeichert."); await showActuator(actuatorId, "Manuelle Kontext-Auswahl gespeichert.");
} catch (error) { } catch (error) {
@@ -709,6 +775,7 @@ async function removeContextEntity(actuatorId, entityId) {
note: `Entity ${entityId} entfernt`, note: `Entity ${entityId} entfernt`,
}), }),
}); });
invalidateDashboardCache();
await loadConfiguredActuators(); await loadConfiguredActuators();
await showActuator(actuatorId, "Kontext-Entity entfernt."); await showActuator(actuatorId, "Kontext-Entity entfernt.");
} catch (error) { } catch (error) {
@@ -733,6 +800,7 @@ async function evaluateActuator(actuatorId) {
const message = record.behavior.prediction const message = record.behavior.prediction
? `Prüfung ${checkedAt}: ${record.behavior.prediction.target_state} mit ${Math.round(record.behavior.prediction.confidence * 100)} % vorhergesagt.` ? `Prüfung ${checkedAt}: ${record.behavior.prediction.target_state} mit ${Math.round(record.behavior.prediction.confidence * 100)} % vorhergesagt.`
: `Prüfung ${checkedAt}: Kein frischer passender Sensorwechsel erkannt; aktuell ist keine Aktion fällig.`; : `Prüfung ${checkedAt}: Kein frischer passender Sensorwechsel erkannt; aktuell ist keine Aktion fällig.`;
invalidateDashboardCache();
await loadConfiguredActuators(); await loadConfiguredActuators();
await showActuator(actuatorId, message); await showActuator(actuatorId, message);
} catch (error) { } catch (error) {
@@ -750,6 +818,7 @@ async function sendFeedback(actuatorId, correct) {
expected_state: expectedState || null, expected_state: expectedState || null,
}), }),
}); });
invalidateDashboardCache();
await loadConfiguredActuators(); await loadConfiguredActuators();
await showActuator(actuatorId, correct ? "Vorhersage als korrekt gelernt." : "Vorhersage als falsch markiert."); await showActuator(actuatorId, correct ? "Vorhersage als korrekt gelernt." : "Vorhersage als falsch markiert.");
} catch (error) { } catch (error) {
@@ -775,6 +844,7 @@ async function setActivation(actuatorId, active, pauseMatchingAutomations, resto
restore_paused_automations: restorePausedAutomations, restore_paused_automations: restorePausedAutomations,
}), }),
}); });
invalidateDashboardCache();
await loadConfiguredActuators(); await loadConfiguredActuators();
await showActuator(actuatorId); await showActuator(actuatorId);
} catch (error) { } catch (error) {
@@ -793,6 +863,7 @@ async function setRelatedAutomation(actuatorId, automationEntityId, enabled) {
enabled, enabled,
}), }),
}); });
invalidateDashboardCache();
await loadConfiguredActuators(); await loadConfiguredActuators();
await showActuator(actuatorId); await showActuator(actuatorId);
} catch (error) { } catch (error) {

View File

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

View File

@@ -310,6 +310,48 @@ def test_reconciliation_does_not_auto_select_overload_sensors_by_power_area(
assert all(candidate.auto_accepted is False for candidate in record.context_candidates) assert all(candidate.auto_accepted is False for candidate in record.context_candidates)
def test_fan_prefers_humidity_over_power_sensor(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [
HaEntitySummary(
entity_id="fan.bad_lueftung",
domain="fan",
friendly_name="Bad Lüftung",
area_name="Bad",
),
HaEntitySummary(
entity_id="sensor.bad_luftfeuchtigkeit",
domain="sensor",
device_class="humidity",
state_class="measurement",
unit_of_measurement="%",
friendly_name="Bad Luftfeuchtigkeit",
area_name="Bad",
),
HaEntitySummary(
entity_id="sensor.bad_power",
domain="sensor",
device_class="power",
state_class="measurement",
unit_of_measurement="W",
friendly_name="Bad Leistung",
area_name="Bad",
),
]
service = _service(
tmp_path,
entities,
{
"sensor.bad_luftfeuchtigkeit": _points(8, start, 55.0),
"sensor.bad_power": _points(8, start, 5.0),
},
)
record = service.configure_actuator("fan.bad_lueftung")
assert record.assignment.selected_numeric_entity_id == "sensor.bad_luftfeuchtigkeit"
def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Path) -> None: def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc) start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [ entities = [
@@ -358,3 +400,39 @@ def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Pa
assert record.assignment.selected_context_entity_ids == ["sensor.abstellkammer_illuminance"] assert record.assignment.selected_context_entity_ids == ["sensor.abstellkammer_illuminance"]
assert record.assignment.source is AssignmentSource.MANUAL assert record.assignment.source is AssignmentSource.MANUAL
assert record.manual_override is not None assert record.manual_override is not None
def test_manual_assignment_evidence_is_not_duplicated(tmp_path: Path) -> None:
entities = [
HaEntitySummary(
entity_id="light.abstellkammer",
domain="light",
friendly_name="Abstellkammer Licht",
area_name="Abstellkammer",
),
HaEntitySummary(
entity_id="binary_sensor.abstellkammer_motion",
domain="binary_sensor",
device_class="motion",
friendly_name="Abstellkammer Bewegung",
area_name="Abstellkammer",
),
]
service = _service(tmp_path, entities, {})
service.configure_actuator("light.abstellkammer")
for _ in range(3):
service.set_manual_assignment(
"light.abstellkammer",
numeric_entity_id=None,
context_entity_ids=["binary_sensor.abstellkammer_motion"],
note="Manuell gesetzt",
)
record = service.get_actuator("light.abstellkammer")
candidate = next(
item
for item in record.context_candidates
if item.entity_id == "binary_sensor.abstellkammer_motion"
)
assert candidate.evidence.count("Manuell vom Nutzer als relevant festgelegt.") == 1

View File

@@ -94,7 +94,8 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
connect.assert_called_once_with( connect.assert_called_once_with(
"ws://homeassistant:8123/api/websocket", "ws://homeassistant:8123/api/websocket",
ping_interval=None, ping_interval=20,
ping_timeout=10,
) )
assert fake_ws.sent == [ assert fake_ws.sent == [
{"type": "auth", "access_token": "test-token"}, {"type": "auth", "access_token": "test-token"},
@@ -110,6 +111,7 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
mock_app.state.behavior_engine = mock_engine mock_app.state.behavior_engine = mock_engine
mock_app.state.ha_reader = _FakeHaReader() mock_app.state.ha_reader = _FakeHaReader()
mock_store = ActuatorStore(tmp_path / "store") mock_store = ActuatorStore(tmp_path / "store")
mock_store.configure("light.test")
mock_app.state.actuator_store = mock_store mock_app.state.actuator_store = mock_store
mock_client = MagicMock() mock_client = MagicMock()