Compare commits

...

3 Commits

Author SHA1 Message Date
98a2b2cc38 Fix dashboard summary status rendering
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-17 00:21:14 +02:00
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
11 changed files with 294 additions and 42 deletions

View File

@@ -1,5 +1,26 @@
# Changelog # Changelog
## 0.7.20 - 2026-06-17
- Dashboard-Übersicht ist kompatibel mit dem leichten Summary-Format und greift
nicht mehr auf `record.behavior.status` aus dem Vollformat zu.
## 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 ## 0.7.17 - 2026-06-16
- WebSocket-Eventpfad ist schneller: irrelevante HA-State-Changes werden vor - WebSocket-Eventpfad ist schneller: irrelevante HA-State-Changes werden vor
dem teuren State-Cache-Listenbau verworfen. dem teuren State-Cache-Listenbau verworfen.

View File

@@ -1,5 +1,5 @@
name: SillyHome Next name: SillyHome Next
version: "0.7.17" version: "0.7.20"
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.17", version="0.7.20",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()

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) {
@@ -488,20 +553,20 @@ async function loadConfiguredActuators() {
<div> <div>
<div><strong>${escapeHtml(entity.friendly_name || record.actuator_entity_id)}</strong></div> <div><strong>${escapeHtml(entity.friendly_name || record.actuator_entity_id)}</strong></div>
<div class="entity-id">${escapeHtml(record.actuator_entity_id)}</div> <div class="entity-id">${escapeHtml(record.actuator_entity_id)}</div>
<div class="${record.behavior.status === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</div> <div class="${(record.behavior_status || record.behavior?.status) === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</div>
</div> </div>
<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.17" version = "0.7.20"
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

@@ -32,5 +32,7 @@ def test_dashboard_is_served_at_root() -> None:
assert "Kein frischer passender Sensorwechsel erkannt" in response.text assert "Kein frischer passender Sensorwechsel erkannt" in response.text
assert "Vorhersage jetzt prüfen" not in response.text assert "Vorhersage jetzt prüfen" not in response.text
assert "record.behavior.activation_ready" in response.text assert "record.behavior.activation_ready" in response.text
assert "record.behavior.status ===" not in response.text
assert "record.behavior_status || record.behavior?.status" in response.text
assert "Automation-Entwurf" not in response.text assert "Automation-Entwurf" not in response.text
assert "Manuelle Overrides" not in response.text assert "Manuelle Overrides" not in response.text