Compare commits

...

6 Commits

Author SHA1 Message Date
954c4511a7 Fix complete dashboard i18n refresh
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-07-26 22:25:13 +02:00
33cce32098 Release SillyHome Next 1.7.4
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-07-26 21:59:21 +02:00
08e41b0198 Improve learning discovery and dashboard i18n
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-07-26 21:57:57 +02:00
1b9db62294 Add simulation apply workflow
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 20:10:53 +02:00
5ca0c53f6a Reduce websocket reconnect load
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 19:17:50 +02:00
8070a85b52 Add actuator simulation tuning
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-18 19:06:47 +02:00
16 changed files with 1597 additions and 77 deletions

View File

@@ -1,5 +1,31 @@
# Changelog
## 1.7.5 - 2026-07-26
- Dashboard-Sprachumschaltung übersetzt jetzt auch dynamisch gerenderte
Status-, Discovery-, Detail-, Listen-, Button- und Aufklapptexte.
- Aufklapp-Hinweise (`expand`/`collapse`) kommen nicht mehr fest aus CSS auf
Deutsch, sondern werden pro Sprache gesetzt.
- Detail-Cache wird beim Sprachwechsel geleert, damit keine alten deutschen
HTML-Fragmente in der englischen Oberfläche sichtbar bleiben.
## 1.7.4 - 2026-07-26
- Dashboard-Sprachumschaltung aktualisiert statische Texte, Labels,
Platzhalter und wichtige Laufzeittexte direkt beim Wechsel.
- Verhaltenslernen speichert Lichtattribute wie Helligkeit und Farbwerte aus
der Home-Assistant-Historie und gibt sie bei Lichtvorhersagen an den
`light.turn_on` Service weiter.
- Kontext-Discovery erkennt Umlaute/Raumvarianten robuster, ignoriert
Markenwörter wie `lidl` als falsche Gemeinsamkeit und bevorzugt Raum-
Präsenzsensoren für Lidl-/Treppenlichter.
- Lüftungen bevorzugen Luftfeuchte und Belegungs-/Präsenzkontext; gelernte
Sensorwechsel dürfen jetzt eine Verzögerung haben, z. B. WC besetzt -> nach
2-3 Minuten Lüftung an.
- Briefkasten-Reset-Buttons können Schrank-/Entnahme-Türen als Kontext
erkennen; Button-Aktoren können im aktiven Modus per `press` ausgeführt
werden.
- PV-/Akku-/Verbrauchssensoren werden als Energiemanagement-Kontext stärker
einsortiert.
## 1.7.0 - 2026-06-18
- Produktiv-Ausbau fuer Schaltvertrauen: persistente Entscheidungs-Timeline,
Event-Latenzmessungen und Dry-run pro Aktor.

View File

@@ -1,5 +1,5 @@
name: SillyHome Next
version: "1.7.0"
version: "1.7.5"
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

@@ -46,6 +46,8 @@ _STOPWORDS = frozenset(
"entity",
"humidity",
"illuminance",
"led",
"lidl",
"light",
"licht",
"lichtschalter",
@@ -138,6 +140,33 @@ _AUTO_CONTEXT_CLASSES = frozenset({
"presence",
"window",
})
_PRESENCE_TOKENS = frozenset({
"besetzt",
"occupied",
"occupancy",
"presence",
"prasenz",
"praesenz",
"motion",
"bewegung",
"bewegungsmelder",
})
_MAILBOX_TOKENS = frozenset({"briefkasten", "mailbox", "post"})
_CABINET_TOKENS = frozenset({"schrank", "cabinet"})
_PV_TOKENS = frozenset({
"pv",
"solar",
"photovoltaik",
"akku",
"batterie",
"battery",
"einspeisung",
"wechselrichter",
"inverter",
"netzbezug",
"grid",
"verbrauch",
})
class ActuatorReconciliationService:
@@ -864,6 +893,18 @@ def _has_context_relationship(actuator: HaEntitySummary, entity: HaEntitySummary
return True
if _metadata_tokens(actuator).intersection(_metadata_tokens(entity)):
return True
actuator_tokens = _metadata_tokens(actuator, include_stopwords=True)
entity_tokens = _metadata_tokens(entity, include_stopwords=True)
if _is_mailbox_reset_candidate(actuator_tokens, entity_tokens, entity):
return True
if actuator.domain in {"fan", "humidifier"} and (
_is_presence_context(entity) or entity.device_class in {"humidity", "moisture"}
):
return True
if actuator.domain in {"climate", "cover", "fan", "humidifier", "light", "switch"} and (
entity_tokens.intersection(_PV_TOKENS)
):
return True
entity_tokens = _metadata_tokens(entity, include_stopwords=True)
return bool(
entity_tokens.intersection(_OUTDOOR_TOKENS)
@@ -878,6 +919,18 @@ def _eligible_for_auto_context(
device_class = candidate.device_class or ""
if device_class in _AUTO_CONTEXT_CLASSES:
return True
if actuator.domain in {"fan", "humidifier"} and device_class in {
"humidity",
"moisture",
"temperature",
}:
return True
if actuator.domain in {"fan", "humidifier", "light", "switch"} and _is_presence_candidate(candidate):
return True
actuator_tokens = _metadata_tokens(actuator, include_stopwords=True)
candidate_tokens = _candidate_tokens(candidate, include_stopwords=True)
if _is_mailbox_reset_candidate(actuator_tokens, candidate_tokens, candidate):
return True
if (
actuator.device_name
and candidate.device_name
@@ -899,6 +952,7 @@ def _score_candidate(
score = 0.0
actuator_tokens = _metadata_tokens(actuator)
entity_tokens = _metadata_tokens(entity)
full_entity_tokens = _metadata_tokens(entity, include_stopwords=True)
overlap = sorted(actuator_tokens.intersection(entity_tokens))
if overlap:
score += min(0.4, 0.1 * len(overlap))
@@ -924,6 +978,31 @@ def _score_candidate(
if entity.device_class in preferred_device_classes:
score += 0.2
evidence.append(f"Passende device_class: {entity.device_class}")
if context and actuator.domain in {"fan", "humidifier"} and entity.device_class in {
"humidity",
"moisture",
}:
score += 0.3
evidence.append("Luftfeuchtigkeit ist primärer Kontext für Lüftung.")
if not context and actuator.domain in {"fan", "humidifier"} and entity.device_class in {
"humidity",
"moisture",
}:
score += 0.3
evidence.append("Luftfeuchtigkeit ist primärer Messwert für Lüftung.")
if context and actuator.domain in {"fan", "humidifier", "light", "switch"} and _is_presence_context(entity):
score += 0.3
evidence.append("Anwesenheit/Belegung ist primärer Schaltkontext.")
if context and _is_mailbox_reset_candidate(
_metadata_tokens(actuator, include_stopwords=True),
_metadata_tokens(entity, include_stopwords=True),
entity,
):
score += 0.45
evidence.append("Briefkasten-Reset passt zur Schrank-/Entnahme-Tür.")
if full_entity_tokens.intersection(_PV_TOKENS):
score += 0.12 if context else 0.18
evidence.append("PV-/Akku-/Verbrauchswert ist als Energiemanagement-Kontext relevant.")
if not context and actuator.domain == "light" and entity.device_class == "illuminance":
score += 0.2
evidence.append("Beleuchtungsstärke wird für Lichtaktoren bevorzugt.")
@@ -1068,11 +1147,83 @@ def _metadata_tokens(entity: HaEntitySummary, *, include_stopwords: bool = False
for value in raw_values:
if value is None:
continue
for token in _TOKEN_PATTERN.findall(value.lower().replace("_", " ")):
if len(token) < 3 or (not include_stopwords and token in _STOPWORDS):
for token in _TOKEN_PATTERN.findall(_normalize_text(value)):
if (len(token) < 3 and token != "wc") or (not include_stopwords and token in _STOPWORDS):
continue
tokens.add(token)
return tokens
return _expand_room_tokens(tokens)
def _candidate_tokens(
candidate: AssignmentCandidate,
*,
include_stopwords: bool = False,
) -> set[str]:
raw_values = [
candidate.entity_id,
candidate.friendly_name,
candidate.area_name,
candidate.device_name,
]
tokens: set[str] = set()
for value in raw_values:
if value is None:
continue
for token in _TOKEN_PATTERN.findall(_normalize_text(value)):
if (len(token) < 3 and token != "wc") or (not include_stopwords and token in _STOPWORDS):
continue
tokens.add(token)
return _expand_room_tokens(tokens)
def _expand_room_tokens(tokens: set[str]) -> set[str]:
expanded = set(tokens)
if "gaste" in expanded:
expanded.add("gaeste")
if {"gaste", "wc"}.issubset(expanded) or {"gaeste", "wc"}.issubset(expanded):
expanded.add("gaestewc")
if {"gaeste", "zimmer"}.issubset(expanded):
expanded.add("gaestezimmer")
return expanded
def _normalize_text(value: str) -> str:
return (
value.lower()
.replace("_", " ")
.replace("ä", "ae")
.replace("ö", "oe")
.replace("ü", "ue")
.replace("ß", "ss")
)
def _is_presence_context(entity: HaEntitySummary) -> bool:
if entity.device_class in {"motion", "occupancy", "presence"}:
return True
return bool(_metadata_tokens(entity, include_stopwords=True).intersection(_PRESENCE_TOKENS))
def _is_presence_candidate(candidate: AssignmentCandidate) -> bool:
if candidate.device_class in {"motion", "occupancy", "presence"}:
return True
return bool(_candidate_tokens(candidate, include_stopwords=True).intersection(_PRESENCE_TOKENS))
def _is_mailbox_reset_candidate(
actuator_tokens: set[str],
context_tokens: set[str],
entity: HaEntitySummary | AssignmentCandidate,
) -> bool:
if not actuator_tokens.intersection(_MAILBOX_TOKENS):
return False
if not context_tokens.intersection(_CABINET_TOKENS):
return False
return entity.domain == "binary_sensor" and entity.device_class in {
"door",
"garage_door",
"opening",
}
def _history_signature(sensor_id: str, points: list[NumericHistoryPoint]) -> str:

View File

@@ -123,12 +123,14 @@ class ModelLifecycleState(BaseModel):
class BehaviorPattern(BaseModel):
target_state: str = Field(min_length=1, max_length=100)
target_attributes: dict[str, object] = Field(default_factory=dict)
minute_of_day: int = Field(ge=0, le=1439)
weekday: int = Field(ge=0, le=6)
context_states: dict[str, str] = Field(default_factory=dict)
trigger_entity_id: str | None = None
trigger_from_state: str | None = None
trigger_to_state: str | None = None
trigger_delay_seconds: int | None = Field(default=None, ge=0)
source: str = Field(default="observed", max_length=40)
weight: float = Field(default=1.0, ge=0.1, le=1.0)
observed_at: datetime
@@ -136,6 +138,7 @@ class BehaviorPattern(BaseModel):
class BehaviorPrediction(BaseModel):
target_state: str
target_attributes: dict[str, object] = Field(default_factory=dict)
confidence: float = Field(ge=0.0, le=1.0)
generated_at: datetime
reason: str
@@ -154,6 +157,19 @@ class DecisionFactor(BaseModel):
evidence: list[str] = Field(default_factory=list)
class SimulationOutcome(BaseModel):
scenario_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
actuator_entity_id: str
sensor_states: dict[str, str] = Field(default_factory=dict)
sensor_weights: dict[str, float] = Field(default_factory=dict)
prediction: BehaviorPrediction | None = None
decision_factors: list[DecisionFactor] = Field(default_factory=list)
would_execute: bool = False
blockers: list[str] = Field(default_factory=list)
score: float = Field(default=0.0, ge=0.0, le=1.0)
recommendation: str = Field(default="", max_length=700)
class DecisionTrace(BaseModel):
trace_id: str = Field(pattern=r"^[a-z0-9_.-]{1,120}$")
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

View File

@@ -10,7 +10,14 @@ from pydantic import BaseModel, Field
from app.actuators.cache_db import DashboardCache
from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import ActuatorRecord, AnomalyEvent, FeedbackKind, ReconciliationState, SensorWeightGroup
from app.actuators.models import (
ActuatorRecord,
AnomalyEvent,
FeedbackKind,
ReconciliationState,
SensorWeightGroup,
SimulationOutcome,
)
from app.actuators.models import JobQueueItem, JobQueueState, JobStatus, SafetyProfile
from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine
@@ -52,6 +59,14 @@ class WeightOverrideRequest(BaseModel):
note: str | None = Field(default=None, max_length=500)
class SimulationRequest(BaseModel):
sensor_states: dict[str, str] = Field(default_factory=dict)
sensor_weights: dict[str, float] = Field(default_factory=dict)
state_options: dict[str, list[str]] = Field(default_factory=dict)
include_current: bool = True
max_results: int = Field(default=8, ge=1, le=20)
class FeedbackRequest(BaseModel):
correct: bool
expected_state: str | None = Field(default=None, max_length=100)
@@ -567,6 +582,28 @@ def evaluate_actuator(
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/simulate", response_model=list[SimulationOutcome])
def simulate_actuator(
actuator_entity_id: str,
payload: SimulationRequest,
request: Request,
) -> list[SimulationOutcome]:
try:
_validate_simulation_payload(payload)
return _behavior(request).simulate(
actuator_entity_id,
sensor_states=payload.sensor_states,
sensor_weights=payload.sensor_weights,
state_options=payload.state_options,
include_current=payload.include_current,
max_results=payload.max_results,
)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/feedback", response_model=ActuatorRecord)
def record_feedback(
actuator_entity_id: str,
@@ -885,6 +922,22 @@ def _validate_weight_payload(payload: WeightOverrideRequest) -> None:
raise ValueError(f"Ungültige Entity-ID in Gruppe {group.name}: {entity_id}")
def _validate_simulation_payload(payload: SimulationRequest) -> None:
for entity_id in [
*payload.sensor_states.keys(),
*payload.sensor_weights.keys(),
*payload.state_options.keys(),
]:
if "." not in entity_id:
raise ValueError(f"Ungültige Entity-ID: {entity_id}")
for entity_id, weight in payload.sensor_weights.items():
if not 0.0 <= weight <= 1.0:
raise ValueError(f"Ungültige Gewichtung für {entity_id}: {weight}")
for entity_id, states in payload.state_options.items():
if not states:
raise ValueError(f"Keine Zustände für {entity_id} angegeben.")
def _reconciliation_state_or_default(request: Request) -> ReconciliationState:
store = getattr(request.app.state, "actuator_store", None)
if not isinstance(store, ActuatorStore):

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import logging
from itertools import product
from collections.abc import Sequence
from datetime import datetime, timedelta, timezone
from time import perf_counter
@@ -29,6 +30,7 @@ from app.actuators.models import (
SafetyProfile,
SafetyStage,
SceneSuggestion,
SimulationOutcome,
TimeProfile,
)
from app.actuators.store import ActuatorStore
@@ -46,10 +48,27 @@ _MAX_DECISION_TRACES = 30
_MAX_LATENCY_MEASUREMENTS = 50
_MAX_FEEDBACK_LOG = 50
_ACTION_LOGBOOK_TOLERANCE = timedelta(seconds=10)
_CONTEXT_TRIGGER_TOLERANCE = timedelta(seconds=3)
_CONTEXT_TRIGGER_TOLERANCE = timedelta(minutes=4)
_OWN_ACTION_TOLERANCE = timedelta(seconds=20)
_SAFE_ACTIVE_DOMAINS = frozenset({"cover", "fan", "humidifier", "light", "switch"})
_SAFE_ACTIVE_DOMAINS = frozenset({
"button",
"cover",
"fan",
"humidifier",
"input_button",
"light",
"switch",
})
_AUTOMATION_CONTEXT_DOMAINS = frozenset({"automation", "script"})
_LIGHT_TARGET_ATTRIBUTES = frozenset({
"brightness",
"color_temp",
"color_temp_kelvin",
"effect",
"hs_color",
"rgb_color",
"xy_color",
})
logger = logging.getLogger(__name__)
@@ -333,10 +352,11 @@ class BehaviorEngine:
record.behavior.patterns,
current_context=current_context,
current_context_changed_at=current_context_changed_at,
context_weights=_context_weights_for(record),
now=now,
min_support=self._settings.min_behavior_actions,
window_minutes=self._settings.prediction_window_minutes,
causal_window_seconds=self._settings.prediction_interval_seconds * 2,
causal_window_seconds=max(self._settings.prediction_interval_seconds * 2, 240),
timezone_name=self._settings.timezone,
)
if prediction is not None:
@@ -436,7 +456,11 @@ class BehaviorEngine:
self._ha_reader.call_service(
domain,
service,
{"entity_id": actuator_entity_id},
_service_data_for_prediction(
actuator_entity_id,
domain,
prediction,
),
)
decision_to_service_ms = _elapsed_ms(service_started_perf)
except (HaClientError, ValueError) as exc:
@@ -514,6 +538,116 @@ class BehaviorEngine:
)
return self._save_behavior(record, behavior)
def simulate(
self,
actuator_entity_id: str,
*,
sensor_states: dict[str, str],
sensor_weights: dict[str, float],
state_options: dict[str, list[str]],
max_results: int,
include_current: bool = True,
) -> list[SimulationOutcome]:
record = self._store.get(actuator_entity_id)
now = datetime.now(timezone.utc)
current_entities = self._ha_reader.read_entities()
entities = {entity.entity_id: entity for entity in current_entities}
actuator = entities.get(actuator_entity_id)
if actuator is None:
raise KeyError("Aktor ist aktuell nicht in Home Assistant verfügbar.")
selected_context_ids = [
entity_id
for entity_id in [
record.assignment.selected_numeric_entity_id,
*record.assignment.selected_context_entity_ids,
]
if entity_id
]
if not selected_context_ids:
return []
base_context = {
entity_id: entities[entity_id].state
for entity_id in selected_context_ids
if entity_id in entities and entities[entity_id].state is not None
}
base_changed_at = {
entity_id: entities[entity_id].last_changed
for entity_id in base_context
}
context_weights = _context_weights_for(record)
for entity_id, weight in sensor_weights.items():
if entity_id in selected_context_ids:
context_weights[entity_id] = max(0.0, min(1.0, weight))
scenarios = _simulation_contexts(
base_context,
sensor_states=sensor_states,
state_options=state_options,
selected_context_ids=selected_context_ids,
include_current=include_current,
)
outcomes: list[SimulationOutcome] = []
for index, context in enumerate(scenarios[:64], start=1):
prediction_context: dict[str, str | None] = dict(context)
changed_at = dict(base_changed_at)
for entity_id, state in context.items():
if base_context.get(entity_id) != state:
changed_at[entity_id] = now
prediction = predict_behavior(
record.behavior.patterns,
current_context=prediction_context,
current_context_changed_at=changed_at,
context_weights=context_weights,
now=now,
min_support=self._settings.min_behavior_actions,
window_minutes=self._settings.prediction_window_minutes,
causal_window_seconds=self._settings.prediction_interval_seconds * 2,
timezone_name=self._settings.timezone,
)
if prediction is not None:
would_execute, blockers = self._assess_safety(record, actuator.state, prediction, now)
recommendation = (
f"Bestes Szenario: {prediction.target_state} mit {prediction.confidence:.0%}."
if would_execute
else (
f"Vorhersage {prediction.target_state} mit {prediction.confidence:.0%}, "
"aber blockiert: " + " ".join(blockers)
)
)
else:
would_execute = False
blockers = ["Keine fällige Vorhersage."]
recommendation = "Dieses Szenario erzeugt keine fällige Vorhersage."
outcomes.append(
SimulationOutcome(
scenario_id=f"scenario-{index}",
actuator_entity_id=actuator_entity_id,
sensor_states=context,
sensor_weights={
entity_id: round(context_weights.get(entity_id, 1.0), 4)
for entity_id in context
},
prediction=prediction,
decision_factors=_decision_factors_for(
record,
prediction_context,
prediction,
context_weights=context_weights,
),
would_execute=would_execute,
blockers=blockers,
score=round(prediction.confidence if prediction is not None else 0.0, 4),
recommendation=recommendation,
)
)
return sorted(
outcomes,
key=lambda item: (
item.prediction is None,
-item.score,
item.scenario_id,
),
)[:max_results]
def record_feedback(
self,
actuator_entity_id: str,
@@ -994,7 +1128,7 @@ class BehaviorEngine:
blockers.append(
f"Sicherheit {prediction.confidence:.0%} liegt unter der Schwelle {threshold:.0%}."
)
if current_state == prediction.target_state:
if _target_reached(record.actuator_entity_id, current_state, prediction):
blockers.append("Zielzustand ist bereits erreicht.")
if not self._cooldown_elapsed(
record.behavior,
@@ -1037,12 +1171,16 @@ class BehaviorEngine:
patterns.append(
BehaviorPattern(
target_state=point.state,
target_attributes=_target_attributes_for(point),
minute_of_day=local.hour * 60 + local.minute,
weekday=local.weekday(),
context_states=contexts,
trigger_entity_id=trigger[0] if trigger else None,
trigger_from_state=trigger[1] if trigger else None,
trigger_to_state=trigger[2] if trigger else None,
trigger_entity_id=trigger[1] if trigger else None,
trigger_from_state=trigger[2] if trigger else None,
trigger_to_state=trigger[3] if trigger else None,
trigger_delay_seconds=(
int(trigger[0].total_seconds()) if trigger else None
),
source=source,
weight=weight,
observed_at=point.timestamp,
@@ -1379,15 +1517,21 @@ def _decision_factors_for(
record: ActuatorRecord,
current_context: dict[str, str | None],
prediction: BehaviorPrediction | None,
*,
context_weights: dict[str, float] | None = None,
) -> list[DecisionFactor]:
factors: list[DecisionFactor] = []
weights = context_weights or {}
candidates = {
candidate.entity_id: candidate
for candidate in [*record.numeric_candidates, *record.context_candidates]
}
for entity_id, state in current_context.items():
candidate = candidates.get(entity_id)
weight = candidate.effective_weight if candidate is not None else 1.0
weight = weights.get(
entity_id,
candidate.effective_weight if candidate is not None else 1.0,
)
relevance = candidate.confidence if candidate is not None else 0.5
contribution = round(min(1.0, weight * relevance), 4)
factors.append(
@@ -1423,6 +1567,62 @@ def _decision_factors_for(
return sorted(factors, key=lambda item: (-item.contribution, item.label))[:12]
def _context_weights_for(record: ActuatorRecord) -> dict[str, float]:
weights = {
candidate.entity_id: candidate.effective_weight
for candidate in [*record.numeric_candidates, *record.context_candidates]
}
override = record.manual_override
if override is not None:
for entity_id, weight in override.sensor_weights.items():
weights[entity_id] = max(0.0, min(1.0, weight))
for group in override.sensor_weight_groups:
for entity_id in group.entity_ids:
weights[entity_id] = max(0.0, min(1.0, group.weight))
return weights
def _simulation_contexts(
base_context: dict[str, str | None],
*,
sensor_states: dict[str, str],
state_options: dict[str, list[str]],
selected_context_ids: list[str],
include_current: bool,
) -> list[dict[str, str]]:
selected = set(selected_context_ids)
base = {
entity_id: state
for entity_id, state in base_context.items()
if entity_id in selected and state is not None
}
for entity_id, state in sensor_states.items():
if entity_id in selected:
base[entity_id] = state
option_items = [
(
entity_id,
list(dict.fromkeys(state for state in states if state))[:6],
)
for entity_id, states in state_options.items()
if entity_id in selected and states
][:6]
contexts: list[dict[str, str]] = []
if include_current or not option_items:
contexts.append(dict(base))
if option_items:
keys = [item[0] for item in option_items]
value_lists = [item[1] for item in option_items]
for values in product(*value_lists):
context = dict(base)
context.update(dict(zip(keys, values, strict=True)))
if context not in contexts:
contexts.append(context)
if len(contexts) >= 64:
break
return contexts
def _knowledge_lines(
record: ActuatorRecord,
sample_count: int,
@@ -1756,6 +1956,7 @@ def predict_behavior(
min_support: int,
window_minutes: int,
current_context_changed_at: dict[str, datetime | None] | None = None,
context_weights: dict[str, float] | None = None,
causal_window_seconds: int = 120,
timezone_name: str = "Europe/Berlin",
) -> BehaviorPrediction | None:
@@ -1765,6 +1966,7 @@ def predict_behavior(
minute_of_day = local.hour * 60 + local.minute
changed_at = current_context_changed_at or {}
by_state: dict[str, list[float]] = {}
attributes_by_state: dict[str, list[tuple[float, dict[str, object]]]] = {}
causal_support_by_state: dict[str, int] = {}
for pattern in patterns:
if pattern.trigger_entity_id and pattern.trigger_to_state:
@@ -1778,7 +1980,11 @@ def predict_behavior(
current_context.get(pattern.trigger_entity_id)
== pattern.trigger_to_state
and trigger_age is not None
and 0 <= trigger_age <= causal_window_seconds
and _trigger_age_matches(
trigger_age,
pattern.trigger_delay_seconds,
causal_window_seconds,
)
):
continue
comparable = [
@@ -1786,17 +1992,16 @@ def predict_behavior(
for entity_id, expected in pattern.context_states.items()
if entity_id in current_context
]
context_score = (
sum(
current_context[entity_id] == expected
for entity_id, expected in comparable
)
/ len(comparable)
if comparable
else 0.5
context_score = _weighted_context_score(
comparable,
current_context,
context_weights or {},
)
score = pattern.weight * (0.85 + 0.15 * context_score)
by_state.setdefault(pattern.target_state, []).append(score)
attributes_by_state.setdefault(pattern.target_state, []).append(
(score, pattern.target_attributes)
)
causal_support_by_state[pattern.target_state] = (
causal_support_by_state.get(pattern.target_state, 0) + 1
)
@@ -1817,16 +2022,18 @@ def predict_behavior(
for entity_id, expected in pattern.context_states.items()
if entity_id in current_context
]
context_score = (
sum(current_context[entity_id] == expected for entity_id, expected in comparable)
/ len(comparable)
if comparable
else 0.5
context_score = _weighted_context_score(
comparable,
current_context,
context_weights or {},
)
score = pattern.weight * (
0.45 * time_score + 0.45 * context_score + 0.10 * weekday_score
)
by_state.setdefault(pattern.target_state, []).append(score)
attributes_by_state.setdefault(pattern.target_state, []).append(
(score, pattern.target_attributes)
)
if not by_state:
return None
target_state, scores = max(
@@ -1840,6 +2047,9 @@ def predict_behavior(
return None
return BehaviorPrediction(
target_state=target_state,
target_attributes=_aggregate_target_attributes(
attributes_by_state.get(target_state, [])
),
confidence=round(confidence, 4),
generated_at=now,
matching_patterns=support,
@@ -1854,9 +2064,106 @@ def predict_behavior(
)
def _weighted_context_score(
comparable: list[tuple[str, str]],
current_context: dict[str, str | None],
context_weights: dict[str, float],
) -> float:
if not comparable:
return 0.5
total_weight = 0.0
matched_weight = 0.0
for entity_id, expected in comparable:
weight = max(0.0, min(1.0, context_weights.get(entity_id, 1.0)))
total_weight += weight
if current_context.get(entity_id) == expected:
matched_weight += weight
if total_weight <= 0:
return 0.5
return matched_weight / total_weight
def _trigger_age_matches(
trigger_age_seconds: float,
expected_delay_seconds: int | None,
causal_window_seconds: int,
) -> bool:
if trigger_age_seconds < 0:
return False
if expected_delay_seconds is None or expected_delay_seconds <= 10:
return trigger_age_seconds <= causal_window_seconds
tolerance = max(30, min(90, causal_window_seconds // 2))
return abs(trigger_age_seconds - expected_delay_seconds) <= tolerance
def _aggregate_target_attributes(
weighted_attributes: list[tuple[float, dict[str, object]]],
) -> dict[str, object]:
if not weighted_attributes:
return {}
result: dict[str, object] = {}
numeric_values: dict[str, list[tuple[float, float]]] = {}
categorical_values: dict[str, dict[str, float]] = {}
for score, attributes in weighted_attributes:
for key, value in attributes.items():
if key not in _LIGHT_TARGET_ATTRIBUTES:
continue
if isinstance(value, bool) or value is None:
continue
if isinstance(value, (int, float)):
numeric_values.setdefault(key, []).append((score, float(value)))
else:
categorical_values.setdefault(key, {}).setdefault(str(value), 0.0)
categorical_values[key][str(value)] += score
for key, values in numeric_values.items():
total_weight = sum(score for score, _ in values)
if total_weight <= 0:
continue
result[key] = round(sum(score * value for score, value in values) / total_weight)
for key, values in categorical_values.items():
if key in result:
continue
result[key] = max(values.items(), key=lambda item: (item[1], item[0]))[0]
return result
def _target_attributes_for(point: StateHistoryPoint) -> dict[str, object]:
if point.state != "on":
return {}
return {
key: value
for key, value in point.attributes.items()
if key in _LIGHT_TARGET_ATTRIBUTES and value is not None
}
def _service_data_for_prediction(
actuator_entity_id: str,
domain: str,
prediction: BehaviorPrediction,
) -> dict[str, object]:
data: dict[str, object] = {"entity_id": actuator_entity_id}
if domain == "light" and prediction.target_state == "on":
data.update(prediction.target_attributes)
return data
def _target_reached(
actuator_entity_id: str,
current_state: str,
prediction: BehaviorPrediction,
) -> bool:
domain = actuator_entity_id.split(".", 1)[0]
if domain == "light" and prediction.target_state == "on" and prediction.target_attributes:
return False
return current_state == prediction.target_state
def service_for_state(domain: str, target_state: str) -> str | None:
if domain in {"fan", "humidifier", "light", "media_player", "remote", "switch"}:
return {"on": "turn_on", "off": "turn_off"}.get(target_state)
if domain in {"button", "input_button"}:
return "press"
if domain == "scene":
return "turn_on" if target_state == "on" else None
if domain == "cover":
@@ -1922,7 +2229,7 @@ def _recent_context_transition(
history: dict[str, StateHistorySeries],
context_ids: list[str],
timestamp: datetime,
) -> tuple[str, str, str] | None:
) -> tuple[timedelta, str, str, str] | None:
nearest: tuple[timedelta, str, str, str] | None = None
for entity_id in context_ids:
series = history.get(entity_id)
@@ -1941,7 +2248,7 @@ def _recent_context_transition(
previous_state = point.state
if nearest is None:
return None
return nearest[1], nearest[2], nearest[3]
return nearest
def _circular_minute_distance(left: int, right: int) -> int:

View File

@@ -78,7 +78,6 @@ class HaClient:
"filter_entity_id": ",".join(entity_ids),
"end_time": end_time.isoformat(),
"minimal_response": "1",
"no_attributes": "1",
},
)
if not isinstance(payload, list):

View File

@@ -21,6 +21,7 @@ class EntityHistorySeries(BaseModel):
class StateHistoryPoint(BaseModel):
timestamp: datetime
state: str
attributes: dict[str, object] = {}
class StateHistorySeries(BaseModel):
@@ -81,8 +82,22 @@ def normalize_state_history_payload(payload: object) -> list[StateHistorySeries]
timestamp = _parse_timestamp(
raw_entry.get("last_changed") or raw_entry.get("last_updated")
)
if not points or points[-1].state != raw_state:
points.append(StateHistoryPoint(timestamp=timestamp, state=raw_state))
attributes = raw_entry.get("attributes")
if not isinstance(attributes, dict):
attributes = {}
if (
not points
or points[-1].state != raw_state
or _relevant_state_attributes(points[-1].attributes)
!= _relevant_state_attributes(attributes)
):
points.append(
StateHistoryPoint(
timestamp=timestamp,
state=raw_state,
attributes=_relevant_state_attributes(attributes),
)
)
if entity_id is not None and points:
points.sort(key=lambda point: point.timestamp)
normalized.append(StateHistorySeries(entity_id=entity_id, points=points))
@@ -176,3 +191,16 @@ def _optional_string(value: object) -> str | None:
if value is None or value == "":
return None
return str(value)
def _relevant_state_attributes(attributes: dict[str, object]) -> dict[str, object]:
keys = {
"brightness",
"color_temp",
"color_temp_kelvin",
"effect",
"hs_color",
"rgb_color",
"xy_color",
}
return {key: attributes[key] for key in keys if key in attributes}

View File

@@ -117,7 +117,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.7.0",
version="1.7.4",
lifespan=lifespan,
)
app.state.settings = load_settings()
@@ -255,6 +255,9 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
ws_url = ha_url.replace("http://", "ws://").replace("https://", "wss://") + "/api/websocket"
auth_token = cast(str, settings.ha_token)
ws_status = getattr(app.state, "ws_status", None)
reconnect_delay = 1.0
relevant_entity_ids: set[str] = set()
relevant_loaded_at = 0.0
while True:
if ws_status is not None:
ws_status.status = "connecting"
@@ -283,6 +286,9 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
logger.info("WebSocket-Verbindung zu Home Assistant hergestellt")
state_cache = await asyncio.to_thread(_load_ha_state_cache, ha_reader)
relevant_entity_ids = await asyncio.to_thread(_relevant_entity_ids, store)
relevant_loaded_at = asyncio.get_running_loop().time()
reconnect_delay = 1.0
if ws_status is not None:
ws_status.status = "connected"
ws_status.error = None
@@ -309,6 +315,12 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
entity_id = event_data.get("entity_id")
if not entity_id:
continue
loop_time = asyncio.get_running_loop().time()
if loop_time - relevant_loaded_at >= 10:
relevant_entity_ids = await asyncio.to_thread(_relevant_entity_ids, store)
relevant_loaded_at = loop_time
if entity_id not in relevant_entity_ids:
continue
new_state = event_data.get("new_state")
_update_ha_state_cache(state_cache, entity_id, new_state)
# Prüfe, ob Entity ein Aktor oder relevanter Kontext ist
@@ -328,17 +340,24 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
websockets.exceptions.InvalidStatus,
OSError,
) as exc:
logger.warning("WebSocket-Verbindung unterbrochen: %s. Wiederholung in 1s...", exc)
delay = reconnect_delay
logger.warning(
"WebSocket-Verbindung unterbrochen: %s. Wiederholung in %.0fs...",
exc,
delay,
)
if ws_status is not None:
ws_status.status = "reconnecting"
ws_status.error = str(exc)
await asyncio.sleep(1)
await asyncio.sleep(delay)
reconnect_delay = min(reconnect_delay * 2, 60.0)
except Exception as exc:
logger.exception("Unerwarteter Fehler im Event-Listener: %s", exc)
if ws_status is not None:
ws_status.status = "error"
ws_status.error = str(exc)
await asyncio.sleep(1)
await asyncio.sleep(reconnect_delay)
reconnect_delay = min(reconnect_delay * 2, 60.0)
# Fallback: periodische Vorhersage falls Event-Stream ausfällt
@@ -353,7 +372,7 @@ async def _fallback_prediction(app: FastAPI) -> None:
await asyncio.sleep(
app.state.settings.prediction_interval_seconds
if websocket_connected
else min(5, app.state.settings.prediction_interval_seconds)
else max(30, app.state.settings.prediction_interval_seconds)
)
# Nur ausführen, wenn WebSocket nicht verbunden ist
ws_status = getattr(app.state, "ws_status", None)
@@ -389,15 +408,14 @@ def _update_ha_state_cache(
)
def _is_relevant_state_change(store: ActuatorStore, entity_id: str) -> bool:
def _relevant_entity_ids(store: ActuatorStore) -> set[str]:
result: set[str] = set()
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
result.add(record.actuator_entity_id)
if record.assignment.selected_numeric_entity_id:
result.add(record.assignment.selected_numeric_entity_id)
result.update(record.assignment.selected_context_entity_ids)
return result
def _ha_entity_from_event(

View File

@@ -65,10 +65,10 @@
.group-panel > summary::-webkit-details-marker { display:none; }
details.collapsible > summary::after,
.manual-context > summary::after,
.group-panel > summary::after { content:"aufklappen"; color:#9fb0be; font-weight:600; font-size:.86rem; }
.group-panel > summary::after { content:attr(data-closed-label); color:#9fb0be; font-weight:600; font-size:.86rem; }
details[open].collapsible > summary::after,
.manual-context[open] > summary::after,
.group-panel[open] > summary::after { content:"zuklappen"; }
.group-panel[open] > summary::after { content:attr(data-open-label); }
.steps { display:grid; grid-template-columns:repeat(auto-fit,minmax(190px,1fr)); gap:10px; margin-top:12px; }
.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; }
@@ -281,12 +281,78 @@ let discoveryLoadPromise = null;
let overviewLoadPromise = null;
let systemLoadPromise = null;
let currentSensorWeightGroups = [];
let latestSimulationResults = new Map();
let visibleActuatorLimit = 24;
const ACTUATOR_RESULT_LIMIT = 50;
const STATUS_TIMEOUT_MS = 2000;
const DASHBOARD_TIMEOUT_MS = 3000;
const I18N = {
de: {
ui: {
tagline: "Geräte, Lernen, Freigaben und Systemzustand.",
menu: "Menü",
nav_status: "Startseite / System",
nav_learning: "Lernen",
nav_discovery: "Discovery & Einrichtung",
nav_settings: "Einstellungen",
page_ready: "Seite bereit, Status folgt ...",
discovery_title: "Discovery & Einrichtung",
entity_id: "Entity-ID",
actuator_placeholder: "z. B. light.licht_abstellraum",
type: "Typ",
all_actuators: "Alle steuerbaren Typen",
lights: "Lichter",
switches: "Schalter / Helper",
buttons: "Buttons",
helper_buttons: "Helper-Buttons",
helper_switches: "Helper-Schalter",
covers: "Rollläden / Cover",
climate: "Heizungen / Klima",
locks: "Schlösser",
fans: "Lüftung / Ventilatoren",
humidifiers: "Befeuchter / Entfeuchter",
media: "TV / Medien",
remotes: "Fernbedienungen",
scenes: "Szenen",
numbers: "Numerische Helper",
valves: "Ventile",
search_list: "Liste durchsuchen",
search_placeholder: "Raum, Gerät oder Entity",
device_list: "Geräteliste",
device_list_lazy: "Geräteliste bei Bedarf laden",
add_device: "Gerät hinzufügen und Beobachtung starten",
load_device_list: "Geräteliste laden",
ready: "Bereit.",
show_suggestions: "Vorschläge anzeigen",
load_suggestions: "Vorschläge laden",
observed_devices: "Beobachtete Geräte",
refresh: "Aktualisieren",
details: "Details",
back: "Zurück",
detail_empty: "Öffne bei einem beobachteten Gerät die Details.",
system_cache: "System & Cache",
check_status: "Status prüfen",
checking: "Prüfung läuft ...",
settings: "Einstellungen",
settings_hint: "Sprache und Standardwerte für die Bedienoberfläche.",
language: "Sprache",
no_prediction: "Keine fällige Aktion",
open: "offen",
no_area: "Ohne Bereich",
loading_start: "Startdaten laden ...",
loading_devices: "Beobachtete Geräte werden geladen ...",
delayed_start: "Startdaten verzögert",
unavailable_start: "Startdaten sind gerade nicht verfügbar.",
system_loading: "Systemübersicht lädt ...",
system_delayed: "Systemübersicht verzögert",
expand: "aufklappen",
collapse: "zuklappen",
context_detected: "Kontext erkannt",
active_approved: "aktiv freigegeben",
shadow_prediction: "Prüfmodus mit Vorhersage",
learning_blocked: "Lernen blockiert",
collecting_actions: "sammelt Handlungen",
},
safety_stage: {
observe: "Nur beobachten",
suggest: "Vorschläge anzeigen",
@@ -357,6 +423,71 @@ const I18N = {
},
},
en: {
ui: {
tagline: "Devices, learning, approvals, and system health.",
menu: "Menu",
nav_status: "Home / System",
nav_learning: "Learning",
nav_discovery: "Discovery & setup",
nav_settings: "Settings",
page_ready: "Page ready, status pending ...",
discovery_title: "Discovery & setup",
entity_id: "Entity ID",
actuator_placeholder: "e.g. light.storage_room",
type: "Type",
all_actuators: "All controllable types",
lights: "Lights",
switches: "Switches / helpers",
buttons: "Buttons",
helper_buttons: "Helper buttons",
helper_switches: "Helper switches",
covers: "Shutters / covers",
climate: "Heating / climate",
locks: "Locks",
fans: "Ventilation / fans",
humidifiers: "Humidifiers / dehumidifiers",
media: "TV / media",
remotes: "Remotes",
scenes: "Scenes",
numbers: "Numeric helpers",
valves: "Valves",
search_list: "Search list",
search_placeholder: "Room, device, or entity",
device_list: "Device list",
device_list_lazy: "Load device list when needed",
add_device: "Add device and start observing",
load_device_list: "Load device list",
ready: "Ready.",
show_suggestions: "Show suggestions",
load_suggestions: "Load suggestions",
observed_devices: "Observed devices",
refresh: "Refresh",
details: "Details",
back: "Back",
detail_empty: "Open details from an observed device.",
system_cache: "System & cache",
check_status: "Check status",
checking: "Checking ...",
settings: "Settings",
settings_hint: "Language and UI defaults.",
language: "Language",
no_prediction: "No due action",
open: "open",
no_area: "No area",
loading_start: "Loading start data ...",
loading_devices: "Loading observed devices ...",
delayed_start: "Start data delayed",
unavailable_start: "Start data is currently unavailable.",
system_loading: "Loading system overview ...",
system_delayed: "System overview delayed",
expand: "expand",
collapse: "collapse",
context_detected: "Context detected",
active_approved: "actively approved",
shadow_prediction: "Review mode with prediction",
learning_blocked: "Learning blocked",
collecting_actions: "collecting actions",
},
safety_stage: {
observe: "Observe only",
suggest: "Show suggestions",
@@ -427,6 +558,182 @@ const I18N = {
},
},
};
const DE_TO_EN_TEXT = new Map(Object.entries({
"Aktiv / Prüfmodus": "Active / review mode",
"Aktive Gewichtung": "Active weight",
"Aktoren": "Actuators",
"Aktualisieren": "Refresh",
"Aktualisiert.": "Refreshed.",
"Aktuelle Blocker:": "Current blockers:",
"Aktuelle Situation auswerten": "Evaluate current situation",
"Aktuell ist kein gelerntes Handlungsmuster fällig.": "No learned action pattern is due right now.",
"Alle relevanten Vorschläge": "All relevant suggestions",
"Als Gruppe speichern": "Save as group",
"Anderes Gerät": "Another device",
"Annahmen": "Assumptions",
"Anomalien": "Anomalies",
"Anomalien offen": "anomalies open",
"Aktor-Simulation": "Actuator simulation",
"Aufgabenliste": "Task list",
"Automationen neu suchen": "Find automations again",
"Automatische Gewichtsanpassungen": "Automatic weight adjustments",
"Automatische Relevanz": "Automatic relevance",
"Bedienoberfläche.": "user interface.",
"Bereit.": "Ready.",
"Bestes Szenario": "Best scenario",
"Bestes Szenario berechnen": "Calculate best scenario",
"Betriebsart:": "Mode:",
"Beitragsfaktoren": "Contributing factors",
"Cache aktuell mit": "Cache current with",
"Cache wird nach Discovery aufgebaut": "Cache will be built after discovery",
"Cache-Zeitpunkt": "Cache time",
"Cooldown Sekunden": "Cooldown seconds",
"Dashboard bereit in": "Dashboard ready in",
"Dashboard bleibt bedienbar.": "The dashboard remains usable.",
"Dauer:": "Duration:",
"Davon eindeutig geregelt:": "Clearly regulated:",
"Details öffnen": "Open details",
"Diese Kontext-Auswahl speichern": "Save this context selection",
"Discovery-Gruppen": "Discovery groups",
"Entfernen": "Remove",
"Entscheidungsakte": "Decision record",
"Entity-IDs der Gruppe": "Group entity IDs",
"Entity-IDs manuell ergänzen": "Add entity IDs manually",
"Ergebnis:": "Result:",
"Erkannt:": "Detected:",
"Erkannte HA-Automationen:": "Detected HA automations:",
"Erneut versuchen: Aktion im Dashboard noch einmal starten; der nächste Lauf schreibt einen neuen Eintrag.": "Try again: start the dashboard action again; the next run will create a new entry.",
"Freigabe": "Approval",
"Freigabebereit": "Ready for approval",
"Freigabestatus:": "Approval status:",
"Freigabestufe": "Approval stage",
"Für die Simulation müssen zuerst Kontextsensoren ausgewählt sein.": "Select context sensors before running a simulation.",
"Geladene Startdaten": "Loaded start data",
"Gelernte Handlungen": "Learned actions",
"Gelernt / Wartet": "Learned / waiting",
"Gerät auswählen": "Select device",
"Geräteliste konnte nicht geladen werden:": "Device list could not be loaded:",
"Geräteliste lädt im Hintergrund ...": "Device list loading in the background ...",
"Geräteliste wird geladen ...": "Device list is loading ...",
"Gewichtung": "Weight",
"Gewichtung korrigieren": "Adjust weight",
"Gewichtung übernehmen": "Apply weight",
"Gewichtung speichern": "Save weight",
"Gewichtungen speichern": "Save weights",
"Gruppen-Gewicht in %": "Group weight in %",
"Gruppen-Gewichtung": "Group weighting",
"Gruppenname": "Group name",
"HA-Automationen pausieren": "pause HA automations",
"HA-Automationen pausiert lassen": "leave HA automations paused",
"HA-Automationen fortsetzen": "resume HA automations",
"Handlungen": "Actions",
"Job p95": "Job p95",
"Jobs aktiv": "Active jobs",
"Kategorie": "Category",
"Kategorien": "categories",
"Keine Annahmen gespeichert.": "No assumptions stored.",
"Keine aktiven Automation-Konflikte erkannt.": "No active automation conflicts detected.",
"Keine eindeutig passende HA-Automation gefunden.": "No clearly matching HA automation found.",
"Keine gesicherten Punkte gespeichert.": "No confirmed points stored.",
"Keine lokalen Sicherheitsblocker für die aktuelle Vorhersage.": "No local safety blockers for the current prediction.",
"Keine offenen Anomalien fuer diesen Aktor.": "No open anomalies for this actuator.",
"Keine passenden Geräte gefunden": "No matching devices found",
"Keine passenden Vorschläge": "No matching suggestions",
"Keine Simulationsergebnisse.": "No simulation results.",
"Keine Unsicherheit gespeichert.": "No uncertainty stored.",
"Kein Kommentar": "No comment",
"Kein numerischen Hauptsensor verwenden": "Do not use a numeric main sensor",
"Keinen numerischen Hauptsensor verwenden": "Do not use a numeric main sensor",
"Kontext": "Context",
"Kontext selbst festlegen": "Set context manually",
"Kontext wird automatisch analysiert ...": "Context is being analyzed automatically ...",
"Kontext-Entity entfernt.": "Context entity removed.",
"Kontext-Entity ausgewählt.": "context entity selected.",
"Kontextzuordnung:": "Context assignment:",
"Kritisch": "Critical",
"Langsame Jobs": "Slow jobs",
"Langsam:": "Slow:",
"Lädt ...": "Loading ...",
"Lernbereit": "Ready to learn",
"Lernbereite Geräte": "Devices ready to learn",
"Lernen": "Learning",
"Lernfortschritt": "Learning progress",
"Lernsystem": "Learning system",
"Letzte automatische Prüfung:": "Last automatic check:",
"Letztes Training:": "Last training:",
"Manuelle Kontext-Auswahl gespeichert.": "Manual context selection saved.",
"Manuelle Sicherheitssperre aktiv": "Manual safety block active",
"Mindest-Sicherheit in %": "Minimum confidence in %",
"Noch keine Aktoren ausgewählt.": "No actuators selected yet.",
"Noch keine aktuelle Entscheidungsfaktoren berechnet.": "No current decision factors calculated yet.",
"Noch keine automatische Gewichtsanpassung.": "No automatic weight adjustment yet.",
"Noch keine Daten": "No data yet",
"Noch keine Gruppe gespeichert.": "No group saved yet.",
"Noch keine Kontext-Entity ausgewählt.": "No context entity selected yet.",
"Noch keine verwendeten Sensoren oder Zustände für eine Gewichtung ausgewählt.": "No sensors or states selected for weighting yet.",
"Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.": "No suitable context detected yet. SillyHome will check again when new HA data arrives.",
"Noch kein Modell-Snapshot gespeichert.": "No model snapshot saved yet.",
"Numerische Helper": "Numeric helpers",
"Optionaler Haupt-Messsensor": "Optional main measurement sensor",
"Passende Home-Assistant-Automationen": "Matching Home Assistant automations",
"Passende Home-Assistant-Automationen neu geprüft.": "Matching Home Assistant automations checked again.",
"Prüfen": "Review",
"Prüfung läuft ...": "Checking ...",
"Rollback möglich": "Rollback available",
"Sicherheit und manuelles Gegensteuern": "Safety and manual override",
"Sicherheitsprofil speichern": "Save safety profile",
"Sicherheitsprofil gespeichert.": "Safety profile saved.",
"SillyHome parallel aktivieren": "Activate SillyHome in parallel",
"SillyHome stoppen; HA-Automationen pausiert lassen": "Stop SillyHome; leave HA automations paused",
"SillyHome stoppen und pausierte HA-Automationen fortsetzen": "Stop SillyHome and resume paused HA automations",
"SillyHome übernehmen lassen": "Let SillyHome take over",
"SillyHome übernehmen lassen und passende HA-Automationen pausieren": "Let SillyHome take over and pause matching HA automations",
"Simulation läuft ...": "Simulation running ...",
"Simulation übernommen.": "Simulation applied.",
"Simulation übernommen und Dry-run gestartet.": "Simulation applied and dry-run started.",
"Simulationsergebnis ist nicht mehr verfügbar. Bitte neu simulieren.": "Simulation result is no longer available. Please simulate again.",
"Simulierte Gewichtung in %": "Simulated weight in %",
"Simulierter Zustand": "Simulated state",
"Sensor-Gewichtung": "Sensor weighting",
"Sensor-Gewichtung gespeichert.": "Sensor weighting saved.",
"Start:": "Start:",
"Status teilweise verfügbar. Das Dashboard bleibt bedienbar.": "Status partially available. The dashboard remains usable.",
"Status wird geprüft ...": "Checking status ...",
"Statusgrund:": "Status reason:",
"System bereit": "System ready",
"Systemübersicht bereit in": "System overview ready in",
"Systemübersicht langsam:": "System overview slow:",
"Systemübersicht verzögert:": "System overview delayed:",
"Übernehmen + Dry-run starten": "Apply + start dry-run",
"Unsicherheit": "Uncertainty",
"Verwendete Sensoren/Zustände ändern": "Change used sensors/states",
"Vorhersage": "Prediction",
"Vorhersage korrekt": "Prediction correct",
"Vorhersage falsch": "Prediction wrong",
"Was SillyHome aktuell vorhersagt": "What SillyHome currently predicts",
"Wissen": "Knowledge",
"Wähle ein Gerät aus der geladenen Liste oder trage eine Entity-ID ein.": "Select a device from the loaded list or enter an entity ID.",
"Würde nach Sicherheitsprüfung schalten.": "Would switch after safety check.",
"Zeitprofile": "Time profiles",
"Zuordnung": "Assignment",
"Zuordnungssicherheit:": "Assignment confidence:",
"Zurück zur Übersicht": "Back to overview",
"Zustände": "States",
"Zustände vergleichen": "Compare states",
"aktiv": "active",
"aktiv freigegeben": "actively approved",
"automatisch erledigt": "automatic",
"bereit": "ready",
"keine": "none",
"keine Vorhersage": "no prediction",
"läuft/offen": "running/open",
"noch offen": "pending",
"offen": "open",
"pausiert": "paused",
"relevant": "relevant",
"statistisch relevanter Kandidat": "statistically relevant candidate",
"unbekannt": "unknown",
}));
let uiLang = localStorage.getItem("sillyhome.ui.language") || "de";
function jumpToSection(target) {
@@ -467,11 +774,16 @@ function showView(viewId) {
function setLanguage(language) {
uiLang = I18N[language] ? language : "de";
localStorage.setItem("sillyhome.ui.language", uiLang);
document.documentElement.lang = uiLang;
cachedDetailHtml.clear();
applyStaticTranslations();
syncSettingsView();
renderActuatorSelect();
if (cachedActuators) renderConfiguredActuators();
if (cachedSystemOverview) renderDashboardStatus(cachedSystemOverview);
if (currentActuatorId && cachedDetailHtml.has(currentActuatorId)) {
document.getElementById("actuator-detail").innerHTML = cachedDetailHtml.get(currentActuatorId);
if (currentActuatorId) {
cachedDetailHtml.delete(currentActuatorId);
void showActuator(currentActuatorId);
}
}
@@ -481,16 +793,161 @@ function syncSettingsView() {
}
function translate(group, value, fallback = "") {
if (value == null || value === "") return fallback || "offen";
if (value == null || value === "") return fallback || ui("open");
return I18N[uiLang]?.[group]?.[value] || fallback || String(value);
}
function ui(key) {
return I18N[uiLang]?.ui?.[key] || I18N.de.ui[key] || key;
}
function setText(selector, key) {
const element = document.querySelector(selector);
if (element) element.textContent = ui(key);
}
function setPlaceholder(selector, key) {
const element = document.querySelector(selector);
if (element) element.placeholder = ui(key);
}
function translateText(value) {
if (uiLang !== "en") return value;
const text = String(value ?? "");
const direct = DE_TO_EN_TEXT.get(text.trim());
if (direct) {
return text.replace(text.trim(), direct);
}
let translated = text;
const replacements = [...DE_TO_EN_TEXT.entries()]
.filter(([source]) => source.length > 3)
.sort(([left], [right]) => right.length - left.length);
for (const [source, target] of replacements) {
translated = translated.replaceAll(source, target);
}
translated = translated
.replace(/Bereit in (\d+) ms/g, "Ready in $1 ms")
.replace(/Weitere (\d+) Geräte anzeigen/g, "Show $1 more devices")
.replace(/(\d+) von (\d+); Suche oder Typ weiter eingrenzen/g, "$1 of $2; narrow search or type")
.replace(/Szenario (\d+)/g, "Scenario $1")
.replace(/Ø (\d+) %/g, "avg. $1 %")
.replace(/(\d+) Beispiele/g, "$1 examples")
.replace(/(\d+) eindeutig/g, "$1 clear")
.replace(/(\d+) % Beitrag/g, "$1 % contribution")
.replace(/(\d+) % Profilklarheit/g, "$1 % profile clarity")
.replace(/mit (\d+) % Sicherheit/g, "with $1 % confidence")
.replace(/Prüfung ([^:]+):/g, "Check $1:")
.replace(/Keine Zusammenfassung/g, "No summary")
.replace(/Dauer: läuft\/offen/g, "Duration: running/open");
return translated;
}
function localizeFragment(root = document) {
for (const summary of root.querySelectorAll?.("details.collapsible > summary, .manual-context > summary, .group-panel > summary") || []) {
summary.dataset.closedLabel = ui("expand");
summary.dataset.openLabel = ui("collapse");
}
if (uiLang !== "en") return;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement;
if (!parent || ["SCRIPT", "STYLE", "TEXTAREA", "INPUT"].includes(parent.tagName)) {
return NodeFilter.FILTER_REJECT;
}
return node.nodeValue.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
},
});
const textNodes = [];
while (walker.nextNode()) textNodes.push(walker.currentNode);
for (const node of textNodes) {
node.nodeValue = translateText(node.nodeValue);
}
for (const input of root.querySelectorAll?.("input[placeholder], textarea[placeholder]") || []) {
input.placeholder = translateText(input.placeholder);
}
for (const optgroup of root.querySelectorAll?.("optgroup[label]") || []) {
optgroup.label = translateText(optgroup.label);
}
}
function applyStaticTranslations() {
document.body.dataset.closedLabel = ui("expand");
document.body.dataset.openLabel = ui("collapse");
for (const summary of document.querySelectorAll("details.collapsible > summary, .manual-context > summary, .group-panel > summary")) {
summary.dataset.closedLabel = ui("expand");
summary.dataset.openLabel = ui("collapse");
}
setText("header .brand p", "tagline");
setText("label[for='section-jump']", "menu");
const navOptions = document.querySelectorAll("#section-jump option");
[
"nav_status",
"nav_learning",
"nav_discovery",
"nav_settings",
].forEach((key, index) => {
if (navOptions[index]) navOptions[index].textContent = ui(key);
});
setText("#load-budget", "page_ready");
setText("#choose h2", "discovery_title");
setText("label[for='actuator-input']", "entity_id");
setPlaceholder("#actuator-input", "actuator_placeholder");
setText("label[for='actuator-domain-filter']", "type");
const domainOptions = document.querySelectorAll("#actuator-domain-filter option");
[
"all_actuators",
"lights",
"switches",
"buttons",
"helper_buttons",
"helper_switches",
"covers",
"climate",
"locks",
"fans",
"humidifiers",
"media",
"remotes",
"scenes",
"numbers",
"valves",
].forEach((key, index) => {
if (domainOptions[index]) domainOptions[index].textContent = ui(key);
});
setText("label[for='actuator-search']", "search_list");
setPlaceholder("#actuator-search", "search_placeholder");
setText("label[for='actuator-select']", "device_list");
const lazyOption = document.querySelector("#actuator-select option[value='']");
if (lazyOption) lazyOption.textContent = ui("device_list_lazy");
const chooseButtons = document.querySelectorAll("#choose > button");
if (chooseButtons[0]) chooseButtons[0].textContent = ui("add_device");
if (chooseButtons[1]) chooseButtons[1].textContent = ui("load_device_list");
setText("#actuator-config-result", "ready");
setText("#choose .manual-context summary", "show_suggestions");
const suggestionButton = document.querySelector("#choose .manual-context button");
if (suggestionButton) suggestionButton.textContent = ui("load_suggestions");
setText("#observed h2", "observed_devices");
const refreshButton = document.querySelector("#observed .panel-title button");
if (refreshButton) refreshButton.textContent = ui("refresh");
setText("#detail h2", "details");
const backButton = document.querySelector("#detail .panel-title button");
if (backButton) backButton.textContent = ui("back");
setText("#actuator-detail", "detail_empty");
setText("#status-section h2", "system_cache");
const statusButton = document.querySelector("#status-section .panel-title button");
if (statusButton) statusButton.textContent = ui("check_status");
setText("#settings h2", "settings");
setText("#settings .muted", "settings_hint");
setText("label[for='language-select']", "language");
localizeFragment(document);
}
function formatDateTime(value) {
if (!value) return "noch offen";
if (!value) return ui("open");
const parsed = new Date(value);
return Number.isNaN(parsed.getTime())
? String(value)
: parsed.toLocaleString("de-DE");
: parsed.toLocaleString(uiLang === "en" ? "en-US" : "de-DE");
}
function uniqueValues(values) {
@@ -531,7 +988,7 @@ async function apiWithTimeout(path, timeoutMs = STATUS_TIMEOUT_MS) {
function lifecycleLabel(record) {
const behaviorStatus = record.behavior_status || record.behavior?.status;
const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status;
if (behaviorStatus === "trained") return "Kontext erkannt";
if (behaviorStatus === "trained") return ui("context_detected");
const labels = {
trained: translate("lifecycle_status", "trained"),
pending_history: translate("lifecycle_status", "pending_history"),
@@ -555,10 +1012,10 @@ function statusClass(record) {
function behaviorLabel(record) {
const mode = record.behavior_mode || record.behavior?.mode;
const status = record.behavior_status || record.behavior?.status;
if (mode === "active") return "aktiv freigegeben";
if (status === "trained") return "Prüfmodus mit Vorhersage";
if (status === "blocked") return "Lernen blockiert";
return "sammelt Handlungen";
if (mode === "active") return ui("active_approved");
if (status === "trained") return ui("shadow_prediction");
if (status === "blocked") return ui("learning_blocked");
return ui("collecting_actions");
}
function predictionLabel(record) {
@@ -566,11 +1023,11 @@ function predictionLabel(record) {
const confidence = record.prediction_confidence ?? record.behavior?.prediction?.confidence;
return target
? `${target} (${Math.round(confidence * 100)} %)`
: "Keine fällige Aktion";
: ui("no_prediction");
}
function entityLabel(entity) {
const area = entity.area_name || "Ohne Bereich";
const area = entity.area_name || ui("no_area");
const name = entity.friendly_name || entity.entity_id;
return `${area} - ${name} (${entity.entity_id})`;
}
@@ -654,9 +1111,9 @@ async function loadOverview() {
async function doLoadOverview() {
const startedAt = performance.now();
const budget = document.getElementById("load-budget");
if (budget) budget.textContent = "Startdaten laden ...";
if (budget) budget.textContent = ui("loading_start");
if (!cachedActuators) {
document.getElementById("configured-actuators").innerHTML = "<p class='muted'>Beobachtete Geräte werden geladen ...</p>";
document.getElementById("configured-actuators").innerHTML = `<p class='muted'>${escapeHtml(ui("loading_devices"))}</p>`;
}
try {
const dashboard = await api("v1/actuators/dashboard/start");
@@ -669,17 +1126,18 @@ async function doLoadOverview() {
if (budget) {
const loadMs = dashboard._load_elapsed_ms;
budget.textContent = loadMs <= DASHBOARD_TIMEOUT_MS
? `Bereit in ${loadMs} ms`
: `Langsam: ${loadMs} ms`;
? `Bereit in ${loadMs} ms`
: `Langsam: ${loadMs} ms`;
budget.textContent = translateText(budget.textContent);
}
} catch (error) {
document.getElementById("configured-actuators").innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
if (budget) budget.textContent = "Startdaten verzögert";
if (budget) budget.textContent = ui("delayed_start");
try {
await loadSummaryData();
renderConfiguredActuators();
} catch (_) {
document.getElementById("configured-actuators").innerHTML = "<div class='empty-state'>Startdaten sind gerade nicht verfügbar.</div>";
document.getElementById("configured-actuators").innerHTML = `<div class='empty-state'>${escapeHtml(ui("unavailable_start"))}</div>`;
}
}
scheduleDashboardExtras();
@@ -696,7 +1154,7 @@ async function loadSystemOverview() {
async function doLoadSystemOverview() {
const startedAt = performance.now();
const budget = document.getElementById("load-budget");
if (budget) budget.textContent = "Systemübersicht lädt ...";
if (budget) budget.textContent = ui("system_loading");
try {
const dashboard = await api("v1/actuators/dashboard/system");
dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt);
@@ -708,11 +1166,12 @@ async function doLoadSystemOverview() {
budget.textContent = loadMs <= DASHBOARD_TIMEOUT_MS
? `Systemübersicht bereit in ${loadMs} ms`
: `Systemübersicht langsam: ${loadMs} ms`;
budget.textContent = translateText(budget.textContent);
}
scheduleDashboardExtras();
} catch (error) {
document.getElementById("status").innerHTML = `<p class="warn">Systemübersicht verzögert: ${escapeHtml(error.message)}</p>`;
if (budget) budget.textContent = "Systemübersicht verzögert";
document.getElementById("status").innerHTML = `<p class="warn">${escapeHtml(translateText(`Systemübersicht verzögert: ${error.message}`))}</p>`;
if (budget) budget.textContent = ui("system_delayed");
}
}
@@ -755,7 +1214,7 @@ async function loadDashboardExtras() {
if (reconciliation.status === "fulfilled") {
const text = document.getElementById("reconciliation-status");
if (text) {
text.textContent = `Letzte automatische Prüfung: ${formatDateTime(reconciliation.value.last_completed_at)}`;
text.textContent = translateText(`Letzte automatische Prüfung: ${formatDateTime(reconciliation.value.last_completed_at)}`);
}
}
} catch (_) {
@@ -792,6 +1251,8 @@ async function loadStatus() {
status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
chips.innerHTML = "";
}
localizeFragment(status);
localizeFragment(chips);
}
function renderDashboardStatus(dashboard) {
@@ -856,6 +1317,9 @@ function renderDashboardStatus(dashboard) {
`<div class="metric"><strong>Cache-Zeitpunkt</strong>${escapeHtml(cache.updated_at || "noch offen")}</div>`,
].join("");
renderJobQueue(jobs);
localizeFragment(status);
localizeFragment(chips);
localizeFragment(stats);
}
function renderJobQueue(jobs) {
@@ -876,6 +1340,7 @@ function renderJobQueue(jobs) {
</div>
`).join("")}
` : "";
localizeFragment(jobsBox);
}
async function loadSummaryData() {
@@ -949,6 +1414,7 @@ function renderActuatorDiscovery() {
options.innerHTML = "";
select.innerHTML = `<option value="">Geräteliste konnte nicht geladen werden: ${escapeHtml(error.message)}</option>`;
}
localizeFragment(document.getElementById("choose"));
}
async function loadActuatorSuggestions() {
@@ -975,6 +1441,7 @@ async function loadActuatorSuggestions() {
} catch (_) {
box.innerHTML = "";
}
localizeFragment(box);
}
function actuatorGroupLabel(domain) {
@@ -1022,6 +1489,7 @@ function renderActuatorSelect() {
</optgroup>
`),
].join("");
localizeFragment(select);
}
async function loadContextOptions(actuatorId) {
@@ -1067,6 +1535,7 @@ function renderManualContextSelect() {
select.innerHTML = filtered.length
? optionGroups(filtered, selectedNow)
: `<option value="">Keine passenden Vorschläge</option>`;
localizeFragment(select);
}
function parseEntityIds(value) {
@@ -1164,6 +1633,7 @@ function renderConfiguredActuators() {
} catch (error) {
box.textContent = error.message;
}
localizeFragment(box);
}
async function showActuator(actuatorId, evaluationMessage = "") {
@@ -1176,6 +1646,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
const box = document.getElementById("actuator-detail");
if (!evaluationMessage && cachedDetailHtml.has(actuatorId)) {
box.innerHTML = cachedDetailHtml.get(actuatorId);
localizeFragment(box);
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
renderConfiguredActuators();
return;
@@ -1252,6 +1723,42 @@ async function showActuator(actuatorId, evaluationMessage = "") {
<button class="secondary" onclick="saveWeightOverrides('${escapeHtml(record.actuator_entity_id)}', true)">Als Gruppe speichern</button>
</details>
`;
const simulationControls = weightedCandidates.length ? `
<details class="manual-context" open>
<summary>Aktor-Simulation</summary>
<p class="muted">Teste Sensorzustände und Gewichtungen, ohne Home Assistant zu schalten. Danach kannst du die beste Gewichtung übernehmen oder direkt in den Dry-run wechseln.</p>
<div class="card-list">
${weightedCandidates.map(candidate => {
const effective = Math.round((candidate.effective_weight ?? 1) * 100);
const currentState = candidate.state || "";
const stateOptions = candidate.domain === "binary_sensor"
? "off,on"
: currentState;
return `
<article class="actuator-card">
<div class="card-title">
<div>
<strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>
<div class="entity-id">${escapeHtml(candidate.entity_id)}</div>
</div>
<span class="chip">Simulation</span>
</div>
<label for="sim-state-${escapeHtml(candidate.entity_id)}">Simulierter Zustand</label>
<input id="sim-state-${escapeHtml(candidate.entity_id)}" data-sim-state-entity="${escapeHtml(candidate.entity_id)}" value="${escapeHtml(currentState)}" placeholder="on, off, 12 ...">
<label for="sim-options-${escapeHtml(candidate.entity_id)}">Zustände vergleichen</label>
<input id="sim-options-${escapeHtml(candidate.entity_id)}" data-sim-options-entity="${escapeHtml(candidate.entity_id)}" value="${escapeHtml(stateOptions)}" placeholder="on,off">
<label for="sim-weight-${escapeHtml(candidate.entity_id)}">Simulierte Gewichtung in %</label>
<input id="sim-weight-${escapeHtml(candidate.entity_id)}" data-sim-weight-entity="${escapeHtml(candidate.entity_id)}" type="number" min="0" max="100" step="5" value="${effective}">
</article>
`;
}).join("")}
</div>
<div class="actions">
<button class="secondary" onclick="simulateActuator('${escapeHtml(record.actuator_entity_id)}')">Bestes Szenario berechnen</button>
</div>
<div id="simulation-result" class="decision-list"></div>
</details>
` : "<p class='muted'>Für die Simulation müssen zuerst Kontextsensoren ausgewählt sein.</p>";
const currentContextControls = contexts.length
? `<ul>${contexts.map(entityId => `
<li>
@@ -1510,17 +2017,20 @@ async function showActuator(actuatorId, evaluationMessage = "") {
<h3>Sensor-Gewichtung</h3>
${weightControls}
${weightGroupControls}
${simulationControls}
<h3>Verwendete Sensoren/Zustände ändern</h3>
${currentContextControls}
${manualAssignment}
`;
box.innerHTML = detailHtml;
cachedDetailHtml.set(actuatorId, detailHtml);
localizeFragment(box);
cachedDetailHtml.set(actuatorId, box.innerHTML);
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
renderConfiguredActuators();
} catch (error) {
box.textContent = error.message;
}
localizeFragment(box);
}
function renderActuatorDetailShell(actuatorId) {
@@ -1537,6 +2047,7 @@ function renderActuatorDetailShell(actuatorId) {
<div class="metric"><strong>Kontext</strong>...</div>
</div>
`;
localizeFragment(document.getElementById("actuator-detail"));
}
async function hydrateContextOptions(record) {
@@ -1564,6 +2075,8 @@ async function hydrateContextOptions(record) {
${contextCategories.map(category => `<option value="${escapeHtml(category)}">${escapeHtml(category)}</option>`).join("")}
`;
renderManualContextSelect();
localizeFragment(numericSelect);
localizeFragment(categorySelect);
}
async function hydrateCurrentContextOptions(actuatorId) {
@@ -1610,6 +2123,99 @@ async function saveWeightOverrides(actuatorId, includeNewGroup = false) {
}
}
async function simulateActuator(actuatorId) {
const sensorStates = {};
const sensorWeights = {};
const stateOptions = {};
for (const input of document.querySelectorAll("[data-sim-state-entity]")) {
const value = input.value.trim();
if (value) sensorStates[input.dataset.simStateEntity] = value;
}
for (const input of document.querySelectorAll("[data-sim-weight-entity]")) {
const value = Number(input.value);
if (Number.isFinite(value)) {
sensorWeights[input.dataset.simWeightEntity] = Math.max(0, Math.min(100, value)) / 100;
}
}
for (const input of document.querySelectorAll("[data-sim-options-entity]")) {
const values = input.value.split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
if (values.length) stateOptions[input.dataset.simOptionsEntity] = values;
}
const box = document.getElementById("simulation-result");
box.innerHTML = "<p class='muted'>Simulation läuft ...</p>";
try {
const results = await api(`v1/actuators/${encodeURIComponent(actuatorId)}/simulate`, {
method: "POST",
body: JSON.stringify({
sensor_states: sensorStates,
sensor_weights: sensorWeights,
state_options: stateOptions,
max_results: 6,
}),
});
latestSimulationResults.set(actuatorId, results);
box.innerHTML = results.length ? results.map((result, index) => {
const prediction = result.prediction;
const factors = result.decision_factors || [];
return `
<div class="decision-row">
<header>
<strong>${index === 0 ? "Bestes Szenario" : `Szenario ${index + 1}`}</strong>
<span class="chip">${prediction ? `${Math.round(prediction.confidence * 100)} % · ${escapeHtml(prediction.target_state)}` : "keine Vorhersage"}</span>
</header>
<p>${escapeHtml(result.recommendation || "")}</p>
<p class="muted">Zustände: ${Object.entries(result.sensor_states || {}).map(([entity, state]) => `${escapeHtml(entity)}=${escapeHtml(state)}`).join(", ") || "keine"}</p>
<p class="muted">Gewichtung: ${Object.entries(result.sensor_weights || {}).map(([entity, weight]) => `${escapeHtml(entity)}=${Math.round(weight * 100)} %`).join(", ") || "Standard"}</p>
${result.blockers?.length ? `<p class="warn">${result.blockers.map(escapeHtml).join(" ")}</p>` : "<p class='ok'>Würde nach Sicherheitsprüfung schalten.</p>"}
${factors.length ? `<ul>${factors.slice(0, 4).map(factor => `<li>${escapeHtml(factor.label)}: ${Math.round((factor.contribution || 0) * 100)} % Beitrag</li>`).join("")}</ul>` : ""}
<div class="actions">
<button class="secondary compact" onclick="applySimulationWeights('${escapeHtml(actuatorId)}', '${escapeHtml(result.scenario_id)}', false)">Gewichtung übernehmen</button>
<button class="compact" onclick="applySimulationWeights('${escapeHtml(actuatorId)}', '${escapeHtml(result.scenario_id)}', true)">Übernehmen + Dry-run starten</button>
</div>
</div>
`;
}).join("") : "<p class='muted'>Keine Simulationsergebnisse.</p>";
} catch (error) {
box.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
}
localizeFragment(box);
}
async function applySimulationWeights(actuatorId, scenarioId, startDryRun) {
const result = (latestSimulationResults.get(actuatorId) || [])
.find(item => item.scenario_id === scenarioId);
if (!result) {
alert("Simulationsergebnis ist nicht mehr verfügbar. Bitte neu simulieren.");
return;
}
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/weights`, {
method: "POST",
body: JSON.stringify({
sensor_weights: result.sensor_weights || {},
sensor_weight_groups: currentSensorWeightGroups,
note: `Aus Simulation ${scenarioId} übernommen`,
}),
});
if (startDryRun) {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/dry-run`, {
method: "POST",
body: JSON.stringify({enabled: true}),
});
}
invalidateDashboardCache();
await loadConfiguredActuators();
await showActuator(
actuatorId,
startDryRun
? "Simulation übernommen und Dry-run gestartet."
: "Simulation übernommen.",
);
} catch (error) {
alert(error.message);
}
}
async function saveManualAssignment(actuatorId) {
const numericEntityId = document.getElementById("manual-numeric-select").value || null;
const selectedContextIds = Array.from(
@@ -1820,9 +2426,11 @@ async function removeActuator(actuatorId) {
}
async function startDashboard() {
document.getElementById("status").innerHTML = "<p class='muted'>Status lädt nach ...</p>";
document.getElementById("configured-actuators").innerHTML = "<div class='empty-state'>Öffne „Lernen“, um Geräte zu laden.</div>";
document.getElementById("actuator-detail").innerHTML = "<div class='empty-state'>Wähle später ein Gerät aus der Übersicht.</div>";
document.documentElement.lang = uiLang;
applyStaticTranslations();
document.getElementById("status").innerHTML = `<p class='muted'>${escapeHtml(uiLang === "en" ? "Status loading ..." : "Status lädt nach ...")}</p>`;
document.getElementById("configured-actuators").innerHTML = `<div class='empty-state'>${escapeHtml(uiLang === "en" ? "Open Learning to load devices." : "Öffne „Lernen“, um Geräte zu laden.")}</div>`;
document.getElementById("actuator-detail").innerHTML = `<div class='empty-state'>${escapeHtml(uiLang === "en" ? "Select a device from the overview later." : "Wähle später ein Gerät aus der Übersicht.")}</div>`;
syncSettingsView();
const initialView = localStorage.getItem("sillyhome.ui.view") === "detail"
? "observed"

View File

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

View File

@@ -87,7 +87,7 @@ def _service(
def test_reconciliation_auto_assigns_and_trains_numeric_model(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
start = datetime.now(timezone.utc) - timedelta(days=1)
entities = [
HaEntitySummary(
entity_id="light.abstellkammer",
@@ -352,6 +352,100 @@ def test_fan_prefers_humidity_over_power_sensor(tmp_path: Path) -> None:
assert record.assignment.selected_numeric_entity_id == "sensor.bad_luftfeuchtigkeit"
def test_lidl_light_uses_room_presence_not_brand_overlap(tmp_path: Path) -> None:
entities = [
HaEntitySummary(
entity_id="light.lidl_kuche",
domain="light",
friendly_name="Lidl Küche",
),
HaEntitySummary(
entity_id="light.lidl_wohnzimmer",
domain="light",
friendly_name="Lidl Wohnzimmer",
),
HaEntitySummary(
entity_id="binary_sensor.pir_kuche_motion_detection",
domain="binary_sensor",
device_class="motion",
friendly_name="Bewegungsmelder",
device_name="PIR_Küche",
),
HaEntitySummary(
entity_id="binary_sensor.pir_wohnzimmer_sensor_state_any",
domain="binary_sensor",
device_class="motion",
friendly_name="Bewegungsmelder",
device_name="PIR_Wohnzimmer",
),
]
service = _service(tmp_path, entities, {})
record = service.configure_actuator("light.lidl_kuche")
assert record.assignment.selected_context_entity_ids == [
"binary_sensor.pir_kuche_motion_detection"
]
def test_mailbox_reset_button_uses_cabinet_door_context(tmp_path: Path) -> None:
entities = [
HaEntitySummary(
entity_id="button.smart_mailbox_als_geleert_markieren",
domain="button",
friendly_name="Smart Mailbox Als geleert markieren",
),
HaEntitySummary(
entity_id="binary_sensor.schrank_strasse_open",
domain="binary_sensor",
device_class="door",
friendly_name="Schrank Straße",
),
]
service = _service(tmp_path, entities, {})
record = service.configure_actuator("button.smart_mailbox_als_geleert_markieren")
assert record.assignment.selected_context_entity_ids == [
"binary_sensor.schrank_strasse_open"
]
assert record.assignment.review_required is False
def test_fan_auto_selects_humidity_and_occupancy_context(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [
HaEntitySummary(
entity_id="humidifier.gastewc_luftung",
domain="humidifier",
friendly_name="GästeWC Lüftung",
),
HaEntitySummary(
entity_id="sensor.pir_gastewc_humidity",
domain="sensor",
device_class="humidity",
state_class="measurement",
unit_of_measurement="%",
friendly_name="Gäste WC Luftfeuchtigkeit",
),
HaEntitySummary(
entity_id="input_boolean.gaste_wc_occupied",
domain="input_boolean",
friendly_name="gaste_wc_occupied",
),
]
service = _service(
tmp_path,
entities,
{"sensor.pir_gastewc_humidity": _points(8, start, 55.0)},
)
record = service.configure_actuator("humidifier.gastewc_luftung")
assert record.assignment.selected_numeric_entity_id == "sensor.pir_gastewc_humidity"
assert "input_boolean.gaste_wc_occupied" in record.assignment.selected_context_entity_ids
def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone
from pathlib import Path
from time import perf_counter
from zoneinfo import ZoneInfo
import pytest
from fastapi.testclient import TestClient
@@ -10,7 +11,7 @@ from fastapi.testclient import TestClient
from app.api.v1.actuators import _deduplicate_actuator_ids
from app.actuators.cache_db import DashboardCache
from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import JobStatus, ModelSnapshot
from app.actuators.models import BehaviorPattern, JobStatus, ModelSnapshot
from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine
from app.config import Settings
@@ -272,6 +273,91 @@ def test_weight_override_endpoint_updates_sensor_relevance(tmp_path: Path) -> No
assert numeric["sensor.abstellkammer_illuminance"]["effective_weight"] == 0.75
def test_actuator_simulation_ranks_sensor_states_without_switching(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
client.post(
"/v1/actuators/light.abstellkammer/assignment",
json={
"numeric_entity_id": "sensor.abstellkammer_illuminance",
"context_entity_ids": ["binary_sensor.abstellkammer_motion"],
},
)
store = app.state.actuator_store
record = store.get("light.abstellkammer")
now = datetime.now(timezone.utc)
local = now.astimezone(ZoneInfo("Europe/Berlin"))
local_minute = local.hour * 60 + local.minute
patterns = [
BehaviorPattern(
target_state="on",
minute_of_day=local_minute,
weekday=now.weekday(),
context_states={
"sensor.abstellkammer_illuminance": "12",
"binary_sensor.abstellkammer_motion": "on",
},
source="user",
weight=1.0,
observed_at=now,
)
for _ in range(3)
]
patterns.extend(
[
BehaviorPattern(
target_state="off",
minute_of_day=local_minute,
weekday=now.weekday(),
context_states={
"sensor.abstellkammer_illuminance": "12",
"binary_sensor.abstellkammer_motion": "off",
},
source="user",
weight=0.5,
observed_at=now,
)
for _ in range(3)
]
)
store.upsert(
record.model_copy(
update={
"behavior": record.behavior.model_copy(
update={
"patterns": patterns,
"sample_count": len(patterns),
"high_confidence_sample_count": len(patterns),
"activation_ready": True,
"activation_reason": "Testfreigabe.",
}
)
}
)
)
response = client.post(
"/v1/actuators/light.abstellkammer/simulate",
json={
"state_options": {"binary_sensor.abstellkammer_motion": ["off", "on"]},
"sensor_weights": {
"binary_sensor.abstellkammer_motion": 1.0,
"sensor.abstellkammer_illuminance": 0.25,
},
"max_results": 2,
},
)
assert response.status_code == 200
payload = response.json()
assert len(payload) == 2
assert payload[0]["prediction"]["target_state"] == "on"
assert payload[0]["sensor_states"]["binary_sensor.abstellkammer_motion"] == "on"
assert payload[0]["sensor_weights"]["sensor.abstellkammer_illuminance"] == 0.25
assert app.state.ha_reader.service_calls == []
def test_safety_profile_can_block_actuator_manually(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)

View File

@@ -646,6 +646,81 @@ def test_prediction_ignores_stale_causal_context_state() -> None:
) is None
def test_prediction_respects_learned_context_delay() -> None:
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
patterns = [
BehaviorPattern(
target_state="on",
minute_of_day=60,
weekday=0,
context_states={"input_boolean.gaste_wc_occupied": "on"},
trigger_entity_id="input_boolean.gaste_wc_occupied",
trigger_from_state="off",
trigger_to_state="on",
trigger_delay_seconds=180,
source="automation",
weight=1.0,
observed_at=now - timedelta(days=days_ago),
)
for days_ago in (3, 2, 1)
]
early = predict_behavior(
patterns,
current_context={"input_boolean.gaste_wc_occupied": "on"},
current_context_changed_at={
"input_boolean.gaste_wc_occupied": now - timedelta(seconds=30)
},
now=now,
min_support=3,
window_minutes=30,
causal_window_seconds=240,
)
due = predict_behavior(
patterns,
current_context={"input_boolean.gaste_wc_occupied": "on"},
current_context_changed_at={
"input_boolean.gaste_wc_occupied": now - timedelta(seconds=185)
},
now=now,
min_support=3,
window_minutes=30,
causal_window_seconds=240,
)
assert early is None
assert due is not None
assert due.target_state == "on"
def test_light_prediction_carries_brightness_attributes() -> None:
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
patterns = [
BehaviorPattern(
target_state="on",
target_attributes={"brightness": brightness},
minute_of_day=now.astimezone().hour * 60 + now.astimezone().minute,
weekday=now.astimezone().weekday(),
context_states={"binary_sensor.pir_kuche_motion_detection": "on"},
source="automation",
weight=1.0,
observed_at=now - timedelta(days=days_ago),
)
for days_ago, brightness in zip((3, 2, 1), (80, 90, 100), strict=True)
]
prediction = predict_behavior(
patterns,
current_context={"binary_sensor.pir_kuche_motion_detection": "on"},
now=now,
min_support=3,
window_minutes=30,
)
assert prediction is not None
assert prediction.target_attributes["brightness"] == 90
def test_state_change_uses_websocket_context_state_for_immediate_action(
tmp_path: Path,
) -> None:

View File

@@ -120,6 +120,28 @@ def test_normalize_state_history_keeps_categorical_changes() -> None:
assert [point.state for point in result[0].points] == ["off", "on"]
def test_normalize_state_history_keeps_light_attribute_changes() -> None:
result = normalize_state_history_payload(
[
[
{
"entity_id": "light.office",
"state": "on",
"attributes": {"brightness": 80, "friendly_name": "Office"},
"last_changed": "2026-06-01T08:00:00+00:00",
},
{
"state": "on",
"attributes": {"brightness": 120, "friendly_name": "Office"},
"last_changed": "2026-06-01T08:05:00+00:00",
},
]
]
)
assert [point.attributes["brightness"] for point in result[0].points] == [80, 120]
def test_normalize_logbook_preserves_action_origin() -> None:
result = normalize_logbook_payload(
[

View File

@@ -126,6 +126,43 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
assert mock_app.state.ws_status.error is None
def test_ha_event_listener_skips_unrelated_state_change(tmp_path: Path) -> None:
async def run_test() -> None:
fake_ws = _FakeWebSocket(
[
'{"type":"auth_required"}',
'{"type":"auth_ok"}',
(
'{"type":"event","event":{"event_type":"state_changed",'
'"data":{"entity_id":"sensor.unused","new_state":{"state":"on"}}}}'
),
asyncio.CancelledError(),
]
)
with patch("websockets.connect", return_value=fake_ws):
try:
await _ha_event_listener(mock_app, mock_client)
except asyncio.CancelledError:
pass
mock_app = MagicMock()
mock_app.state.settings = MagicMock()
mock_app.state.settings.ha_url = "http://homeassistant:8123"
mock_app.state.settings.ha_token = "test-token"
mock_app.state.ws_status = MagicMock()
mock_engine = _RecordingBehaviorEngine(tmp_path)
mock_app.state.behavior_engine = mock_engine
mock_app.state.ha_reader = _FakeHaReader()
mock_store = ActuatorStore(tmp_path / "store")
mock_store.configure("light.test")
mock_app.state.actuator_store = mock_store
mock_client = MagicMock()
anyio.run(run_test)
assert mock_engine.state_changes == []
def test_lifespan_skips_event_listener_without_ha_config() -> None:
app = FastAPI()
app.state.settings = MagicMock()