Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 954c4511a7 | |||
| 33cce32098 | |||
| 08e41b0198 |
26
CHANGELOG.md
26
CHANGELOG.md
@@ -1,5 +1,31 @@
|
|||||||
# Changelog
|
# 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
|
## 1.7.0 - 2026-06-18
|
||||||
- Produktiv-Ausbau fuer Schaltvertrauen: persistente Entscheidungs-Timeline,
|
- Produktiv-Ausbau fuer Schaltvertrauen: persistente Entscheidungs-Timeline,
|
||||||
Event-Latenzmessungen und Dry-run pro Aktor.
|
Event-Latenzmessungen und Dry-run pro Aktor.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: SillyHome Next
|
name: SillyHome Next
|
||||||
version: "1.7.3"
|
version: "1.7.5"
|
||||||
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
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ _STOPWORDS = frozenset(
|
|||||||
"entity",
|
"entity",
|
||||||
"humidity",
|
"humidity",
|
||||||
"illuminance",
|
"illuminance",
|
||||||
|
"led",
|
||||||
|
"lidl",
|
||||||
"light",
|
"light",
|
||||||
"licht",
|
"licht",
|
||||||
"lichtschalter",
|
"lichtschalter",
|
||||||
@@ -138,6 +140,33 @@ _AUTO_CONTEXT_CLASSES = frozenset({
|
|||||||
"presence",
|
"presence",
|
||||||
"window",
|
"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:
|
class ActuatorReconciliationService:
|
||||||
@@ -864,6 +893,18 @@ def _has_context_relationship(actuator: HaEntitySummary, entity: HaEntitySummary
|
|||||||
return True
|
return True
|
||||||
if _metadata_tokens(actuator).intersection(_metadata_tokens(entity)):
|
if _metadata_tokens(actuator).intersection(_metadata_tokens(entity)):
|
||||||
return True
|
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)
|
entity_tokens = _metadata_tokens(entity, include_stopwords=True)
|
||||||
return bool(
|
return bool(
|
||||||
entity_tokens.intersection(_OUTDOOR_TOKENS)
|
entity_tokens.intersection(_OUTDOOR_TOKENS)
|
||||||
@@ -878,6 +919,18 @@ def _eligible_for_auto_context(
|
|||||||
device_class = candidate.device_class or ""
|
device_class = candidate.device_class or ""
|
||||||
if device_class in _AUTO_CONTEXT_CLASSES:
|
if device_class in _AUTO_CONTEXT_CLASSES:
|
||||||
return True
|
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 (
|
if (
|
||||||
actuator.device_name
|
actuator.device_name
|
||||||
and candidate.device_name
|
and candidate.device_name
|
||||||
@@ -899,6 +952,7 @@ def _score_candidate(
|
|||||||
score = 0.0
|
score = 0.0
|
||||||
actuator_tokens = _metadata_tokens(actuator)
|
actuator_tokens = _metadata_tokens(actuator)
|
||||||
entity_tokens = _metadata_tokens(entity)
|
entity_tokens = _metadata_tokens(entity)
|
||||||
|
full_entity_tokens = _metadata_tokens(entity, include_stopwords=True)
|
||||||
overlap = sorted(actuator_tokens.intersection(entity_tokens))
|
overlap = sorted(actuator_tokens.intersection(entity_tokens))
|
||||||
if overlap:
|
if overlap:
|
||||||
score += min(0.4, 0.1 * len(overlap))
|
score += min(0.4, 0.1 * len(overlap))
|
||||||
@@ -924,6 +978,31 @@ def _score_candidate(
|
|||||||
if entity.device_class in preferred_device_classes:
|
if entity.device_class in preferred_device_classes:
|
||||||
score += 0.2
|
score += 0.2
|
||||||
evidence.append(f"Passende device_class: {entity.device_class}")
|
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":
|
if not context and actuator.domain == "light" and entity.device_class == "illuminance":
|
||||||
score += 0.2
|
score += 0.2
|
||||||
evidence.append("Beleuchtungsstärke wird für Lichtaktoren bevorzugt.")
|
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:
|
for value in raw_values:
|
||||||
if value is None:
|
if value is None:
|
||||||
continue
|
continue
|
||||||
for token in _TOKEN_PATTERN.findall(value.lower().replace("_", " ")):
|
for token in _TOKEN_PATTERN.findall(_normalize_text(value)):
|
||||||
if len(token) < 3 or (not include_stopwords and token in _STOPWORDS):
|
if (len(token) < 3 and token != "wc") or (not include_stopwords and token in _STOPWORDS):
|
||||||
continue
|
continue
|
||||||
tokens.add(token)
|
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:
|
def _history_signature(sensor_id: str, points: list[NumericHistoryPoint]) -> str:
|
||||||
|
|||||||
@@ -123,12 +123,14 @@ class ModelLifecycleState(BaseModel):
|
|||||||
|
|
||||||
class BehaviorPattern(BaseModel):
|
class BehaviorPattern(BaseModel):
|
||||||
target_state: str = Field(min_length=1, max_length=100)
|
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)
|
minute_of_day: int = Field(ge=0, le=1439)
|
||||||
weekday: int = Field(ge=0, le=6)
|
weekday: int = Field(ge=0, le=6)
|
||||||
context_states: dict[str, str] = Field(default_factory=dict)
|
context_states: dict[str, str] = Field(default_factory=dict)
|
||||||
trigger_entity_id: str | None = None
|
trigger_entity_id: str | None = None
|
||||||
trigger_from_state: str | None = None
|
trigger_from_state: str | None = None
|
||||||
trigger_to_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)
|
source: str = Field(default="observed", max_length=40)
|
||||||
weight: float = Field(default=1.0, ge=0.1, le=1.0)
|
weight: float = Field(default=1.0, ge=0.1, le=1.0)
|
||||||
observed_at: datetime
|
observed_at: datetime
|
||||||
@@ -136,6 +138,7 @@ class BehaviorPattern(BaseModel):
|
|||||||
|
|
||||||
class BehaviorPrediction(BaseModel):
|
class BehaviorPrediction(BaseModel):
|
||||||
target_state: str
|
target_state: str
|
||||||
|
target_attributes: dict[str, object] = Field(default_factory=dict)
|
||||||
confidence: float = Field(ge=0.0, le=1.0)
|
confidence: float = Field(ge=0.0, le=1.0)
|
||||||
generated_at: datetime
|
generated_at: datetime
|
||||||
reason: str
|
reason: str
|
||||||
|
|||||||
@@ -48,10 +48,27 @@ _MAX_DECISION_TRACES = 30
|
|||||||
_MAX_LATENCY_MEASUREMENTS = 50
|
_MAX_LATENCY_MEASUREMENTS = 50
|
||||||
_MAX_FEEDBACK_LOG = 50
|
_MAX_FEEDBACK_LOG = 50
|
||||||
_ACTION_LOGBOOK_TOLERANCE = timedelta(seconds=10)
|
_ACTION_LOGBOOK_TOLERANCE = timedelta(seconds=10)
|
||||||
_CONTEXT_TRIGGER_TOLERANCE = timedelta(seconds=3)
|
_CONTEXT_TRIGGER_TOLERANCE = timedelta(minutes=4)
|
||||||
_OWN_ACTION_TOLERANCE = timedelta(seconds=20)
|
_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"})
|
_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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -339,7 +356,7 @@ class BehaviorEngine:
|
|||||||
now=now,
|
now=now,
|
||||||
min_support=self._settings.min_behavior_actions,
|
min_support=self._settings.min_behavior_actions,
|
||||||
window_minutes=self._settings.prediction_window_minutes,
|
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,
|
timezone_name=self._settings.timezone,
|
||||||
)
|
)
|
||||||
if prediction is not None:
|
if prediction is not None:
|
||||||
@@ -439,7 +456,11 @@ class BehaviorEngine:
|
|||||||
self._ha_reader.call_service(
|
self._ha_reader.call_service(
|
||||||
domain,
|
domain,
|
||||||
service,
|
service,
|
||||||
{"entity_id": actuator_entity_id},
|
_service_data_for_prediction(
|
||||||
|
actuator_entity_id,
|
||||||
|
domain,
|
||||||
|
prediction,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
decision_to_service_ms = _elapsed_ms(service_started_perf)
|
decision_to_service_ms = _elapsed_ms(service_started_perf)
|
||||||
except (HaClientError, ValueError) as exc:
|
except (HaClientError, ValueError) as exc:
|
||||||
@@ -1107,7 +1128,7 @@ class BehaviorEngine:
|
|||||||
blockers.append(
|
blockers.append(
|
||||||
f"Sicherheit {prediction.confidence:.0%} liegt unter der Schwelle {threshold:.0%}."
|
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.")
|
blockers.append("Zielzustand ist bereits erreicht.")
|
||||||
if not self._cooldown_elapsed(
|
if not self._cooldown_elapsed(
|
||||||
record.behavior,
|
record.behavior,
|
||||||
@@ -1150,12 +1171,16 @@ class BehaviorEngine:
|
|||||||
patterns.append(
|
patterns.append(
|
||||||
BehaviorPattern(
|
BehaviorPattern(
|
||||||
target_state=point.state,
|
target_state=point.state,
|
||||||
|
target_attributes=_target_attributes_for(point),
|
||||||
minute_of_day=local.hour * 60 + local.minute,
|
minute_of_day=local.hour * 60 + local.minute,
|
||||||
weekday=local.weekday(),
|
weekday=local.weekday(),
|
||||||
context_states=contexts,
|
context_states=contexts,
|
||||||
trigger_entity_id=trigger[0] if trigger else None,
|
trigger_entity_id=trigger[1] if trigger else None,
|
||||||
trigger_from_state=trigger[1] if trigger else None,
|
trigger_from_state=trigger[2] if trigger else None,
|
||||||
trigger_to_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,
|
source=source,
|
||||||
weight=weight,
|
weight=weight,
|
||||||
observed_at=point.timestamp,
|
observed_at=point.timestamp,
|
||||||
@@ -1941,6 +1966,7 @@ def predict_behavior(
|
|||||||
minute_of_day = local.hour * 60 + local.minute
|
minute_of_day = local.hour * 60 + local.minute
|
||||||
changed_at = current_context_changed_at or {}
|
changed_at = current_context_changed_at or {}
|
||||||
by_state: dict[str, list[float]] = {}
|
by_state: dict[str, list[float]] = {}
|
||||||
|
attributes_by_state: dict[str, list[tuple[float, dict[str, object]]]] = {}
|
||||||
causal_support_by_state: dict[str, int] = {}
|
causal_support_by_state: dict[str, int] = {}
|
||||||
for pattern in patterns:
|
for pattern in patterns:
|
||||||
if pattern.trigger_entity_id and pattern.trigger_to_state:
|
if pattern.trigger_entity_id and pattern.trigger_to_state:
|
||||||
@@ -1954,7 +1980,11 @@ def predict_behavior(
|
|||||||
current_context.get(pattern.trigger_entity_id)
|
current_context.get(pattern.trigger_entity_id)
|
||||||
== pattern.trigger_to_state
|
== pattern.trigger_to_state
|
||||||
and trigger_age is not None
|
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
|
continue
|
||||||
comparable = [
|
comparable = [
|
||||||
@@ -1969,6 +1999,9 @@ def predict_behavior(
|
|||||||
)
|
)
|
||||||
score = pattern.weight * (0.85 + 0.15 * context_score)
|
score = pattern.weight * (0.85 + 0.15 * context_score)
|
||||||
by_state.setdefault(pattern.target_state, []).append(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[pattern.target_state] = (
|
||||||
causal_support_by_state.get(pattern.target_state, 0) + 1
|
causal_support_by_state.get(pattern.target_state, 0) + 1
|
||||||
)
|
)
|
||||||
@@ -1998,6 +2031,9 @@ def predict_behavior(
|
|||||||
0.45 * time_score + 0.45 * context_score + 0.10 * weekday_score
|
0.45 * time_score + 0.45 * context_score + 0.10 * weekday_score
|
||||||
)
|
)
|
||||||
by_state.setdefault(pattern.target_state, []).append(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:
|
if not by_state:
|
||||||
return None
|
return None
|
||||||
target_state, scores = max(
|
target_state, scores = max(
|
||||||
@@ -2011,6 +2047,9 @@ def predict_behavior(
|
|||||||
return None
|
return None
|
||||||
return BehaviorPrediction(
|
return BehaviorPrediction(
|
||||||
target_state=target_state,
|
target_state=target_state,
|
||||||
|
target_attributes=_aggregate_target_attributes(
|
||||||
|
attributes_by_state.get(target_state, [])
|
||||||
|
),
|
||||||
confidence=round(confidence, 4),
|
confidence=round(confidence, 4),
|
||||||
generated_at=now,
|
generated_at=now,
|
||||||
matching_patterns=support,
|
matching_patterns=support,
|
||||||
@@ -2044,9 +2083,87 @@ def _weighted_context_score(
|
|||||||
return matched_weight / total_weight
|
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:
|
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 in {"button", "input_button"}:
|
||||||
|
return "press"
|
||||||
if domain == "scene":
|
if domain == "scene":
|
||||||
return "turn_on" if target_state == "on" else None
|
return "turn_on" if target_state == "on" else None
|
||||||
if domain == "cover":
|
if domain == "cover":
|
||||||
@@ -2112,7 +2229,7 @@ def _recent_context_transition(
|
|||||||
history: dict[str, StateHistorySeries],
|
history: dict[str, StateHistorySeries],
|
||||||
context_ids: list[str],
|
context_ids: list[str],
|
||||||
timestamp: datetime,
|
timestamp: datetime,
|
||||||
) -> tuple[str, str, str] | None:
|
) -> tuple[timedelta, str, str, str] | None:
|
||||||
nearest: tuple[timedelta, str, str, str] | None = None
|
nearest: tuple[timedelta, str, str, str] | None = None
|
||||||
for entity_id in context_ids:
|
for entity_id in context_ids:
|
||||||
series = history.get(entity_id)
|
series = history.get(entity_id)
|
||||||
@@ -2131,7 +2248,7 @@ def _recent_context_transition(
|
|||||||
previous_state = point.state
|
previous_state = point.state
|
||||||
if nearest is None:
|
if nearest is None:
|
||||||
return None
|
return None
|
||||||
return nearest[1], nearest[2], nearest[3]
|
return nearest
|
||||||
|
|
||||||
|
|
||||||
def _circular_minute_distance(left: int, right: int) -> int:
|
def _circular_minute_distance(left: int, right: int) -> int:
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ class HaClient:
|
|||||||
"filter_entity_id": ",".join(entity_ids),
|
"filter_entity_id": ",".join(entity_ids),
|
||||||
"end_time": end_time.isoformat(),
|
"end_time": end_time.isoformat(),
|
||||||
"minimal_response": "1",
|
"minimal_response": "1",
|
||||||
"no_attributes": "1",
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if not isinstance(payload, list):
|
if not isinstance(payload, list):
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class EntityHistorySeries(BaseModel):
|
|||||||
class StateHistoryPoint(BaseModel):
|
class StateHistoryPoint(BaseModel):
|
||||||
timestamp: datetime
|
timestamp: datetime
|
||||||
state: str
|
state: str
|
||||||
|
attributes: dict[str, object] = {}
|
||||||
|
|
||||||
|
|
||||||
class StateHistorySeries(BaseModel):
|
class StateHistorySeries(BaseModel):
|
||||||
@@ -81,8 +82,22 @@ def normalize_state_history_payload(payload: object) -> list[StateHistorySeries]
|
|||||||
timestamp = _parse_timestamp(
|
timestamp = _parse_timestamp(
|
||||||
raw_entry.get("last_changed") or raw_entry.get("last_updated")
|
raw_entry.get("last_changed") or raw_entry.get("last_updated")
|
||||||
)
|
)
|
||||||
if not points or points[-1].state != raw_state:
|
attributes = raw_entry.get("attributes")
|
||||||
points.append(StateHistoryPoint(timestamp=timestamp, state=raw_state))
|
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:
|
if entity_id is not None and points:
|
||||||
points.sort(key=lambda point: point.timestamp)
|
points.sort(key=lambda point: point.timestamp)
|
||||||
normalized.append(StateHistorySeries(entity_id=entity_id, points=points))
|
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 == "":
|
if value is None or value == "":
|
||||||
return None
|
return None
|
||||||
return str(value)
|
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}
|
||||||
|
|||||||
@@ -117,7 +117,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="1.7.3",
|
version="1.7.4",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
app.state.settings = load_settings()
|
app.state.settings = load_settings()
|
||||||
|
|||||||
@@ -65,10 +65,10 @@
|
|||||||
.group-panel > summary::-webkit-details-marker { display:none; }
|
.group-panel > summary::-webkit-details-marker { display:none; }
|
||||||
details.collapsible > summary::after,
|
details.collapsible > summary::after,
|
||||||
.manual-context > 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,
|
details[open].collapsible > summary::after,
|
||||||
.manual-context[open] > 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; }
|
.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 { background:#121922; border:1px solid var(--border); border-radius:8px; padding:10px; }
|
||||||
.step-number { display:inline-grid; place-items:center; width:28px; height:28px; border-radius:8px; background:var(--complement); color:#03151d; font-weight:900; margin-bottom:8px; }
|
.step-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; }
|
||||||
@@ -288,6 +288,71 @@ const STATUS_TIMEOUT_MS = 2000;
|
|||||||
const DASHBOARD_TIMEOUT_MS = 3000;
|
const DASHBOARD_TIMEOUT_MS = 3000;
|
||||||
const I18N = {
|
const I18N = {
|
||||||
de: {
|
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: {
|
safety_stage: {
|
||||||
observe: "Nur beobachten",
|
observe: "Nur beobachten",
|
||||||
suggest: "Vorschläge anzeigen",
|
suggest: "Vorschläge anzeigen",
|
||||||
@@ -358,6 +423,71 @@ const I18N = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
en: {
|
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: {
|
safety_stage: {
|
||||||
observe: "Observe only",
|
observe: "Observe only",
|
||||||
suggest: "Show suggestions",
|
suggest: "Show suggestions",
|
||||||
@@ -428,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";
|
let uiLang = localStorage.getItem("sillyhome.ui.language") || "de";
|
||||||
|
|
||||||
function jumpToSection(target) {
|
function jumpToSection(target) {
|
||||||
@@ -468,11 +774,16 @@ function showView(viewId) {
|
|||||||
function setLanguage(language) {
|
function setLanguage(language) {
|
||||||
uiLang = I18N[language] ? language : "de";
|
uiLang = I18N[language] ? language : "de";
|
||||||
localStorage.setItem("sillyhome.ui.language", uiLang);
|
localStorage.setItem("sillyhome.ui.language", uiLang);
|
||||||
|
document.documentElement.lang = uiLang;
|
||||||
|
cachedDetailHtml.clear();
|
||||||
|
applyStaticTranslations();
|
||||||
syncSettingsView();
|
syncSettingsView();
|
||||||
|
renderActuatorSelect();
|
||||||
if (cachedActuators) renderConfiguredActuators();
|
if (cachedActuators) renderConfiguredActuators();
|
||||||
if (cachedSystemOverview) renderDashboardStatus(cachedSystemOverview);
|
if (cachedSystemOverview) renderDashboardStatus(cachedSystemOverview);
|
||||||
if (currentActuatorId && cachedDetailHtml.has(currentActuatorId)) {
|
if (currentActuatorId) {
|
||||||
document.getElementById("actuator-detail").innerHTML = cachedDetailHtml.get(currentActuatorId);
|
cachedDetailHtml.delete(currentActuatorId);
|
||||||
|
void showActuator(currentActuatorId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,16 +793,161 @@ function syncSettingsView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function translate(group, value, fallback = "") {
|
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);
|
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) {
|
function formatDateTime(value) {
|
||||||
if (!value) return "noch offen";
|
if (!value) return ui("open");
|
||||||
const parsed = new Date(value);
|
const parsed = new Date(value);
|
||||||
return Number.isNaN(parsed.getTime())
|
return Number.isNaN(parsed.getTime())
|
||||||
? String(value)
|
? String(value)
|
||||||
: parsed.toLocaleString("de-DE");
|
: parsed.toLocaleString(uiLang === "en" ? "en-US" : "de-DE");
|
||||||
}
|
}
|
||||||
|
|
||||||
function uniqueValues(values) {
|
function uniqueValues(values) {
|
||||||
@@ -532,7 +988,7 @@ async function apiWithTimeout(path, timeoutMs = STATUS_TIMEOUT_MS) {
|
|||||||
function lifecycleLabel(record) {
|
function lifecycleLabel(record) {
|
||||||
const behaviorStatus = record.behavior_status || record.behavior?.status;
|
const behaviorStatus = record.behavior_status || record.behavior?.status;
|
||||||
const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status;
|
const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status;
|
||||||
if (behaviorStatus === "trained") return "Kontext erkannt";
|
if (behaviorStatus === "trained") return ui("context_detected");
|
||||||
const labels = {
|
const labels = {
|
||||||
trained: translate("lifecycle_status", "trained"),
|
trained: translate("lifecycle_status", "trained"),
|
||||||
pending_history: translate("lifecycle_status", "pending_history"),
|
pending_history: translate("lifecycle_status", "pending_history"),
|
||||||
@@ -556,10 +1012,10 @@ function statusClass(record) {
|
|||||||
function behaviorLabel(record) {
|
function behaviorLabel(record) {
|
||||||
const mode = record.behavior_mode || record.behavior?.mode;
|
const mode = record.behavior_mode || record.behavior?.mode;
|
||||||
const status = record.behavior_status || record.behavior?.status;
|
const status = record.behavior_status || record.behavior?.status;
|
||||||
if (mode === "active") return "aktiv freigegeben";
|
if (mode === "active") return ui("active_approved");
|
||||||
if (status === "trained") return "Prüfmodus mit Vorhersage";
|
if (status === "trained") return ui("shadow_prediction");
|
||||||
if (status === "blocked") return "Lernen blockiert";
|
if (status === "blocked") return ui("learning_blocked");
|
||||||
return "sammelt Handlungen";
|
return ui("collecting_actions");
|
||||||
}
|
}
|
||||||
|
|
||||||
function predictionLabel(record) {
|
function predictionLabel(record) {
|
||||||
@@ -567,11 +1023,11 @@ function predictionLabel(record) {
|
|||||||
const confidence = record.prediction_confidence ?? record.behavior?.prediction?.confidence;
|
const confidence = record.prediction_confidence ?? record.behavior?.prediction?.confidence;
|
||||||
return target
|
return target
|
||||||
? `${target} (${Math.round(confidence * 100)} %)`
|
? `${target} (${Math.round(confidence * 100)} %)`
|
||||||
: "Keine fällige Aktion";
|
: ui("no_prediction");
|
||||||
}
|
}
|
||||||
|
|
||||||
function entityLabel(entity) {
|
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;
|
const name = entity.friendly_name || entity.entity_id;
|
||||||
return `${area} - ${name} (${entity.entity_id})`;
|
return `${area} - ${name} (${entity.entity_id})`;
|
||||||
}
|
}
|
||||||
@@ -655,9 +1111,9 @@ async function loadOverview() {
|
|||||||
async function doLoadOverview() {
|
async function doLoadOverview() {
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
const budget = document.getElementById("load-budget");
|
const budget = document.getElementById("load-budget");
|
||||||
if (budget) budget.textContent = "Startdaten laden ...";
|
if (budget) budget.textContent = ui("loading_start");
|
||||||
if (!cachedActuators) {
|
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 {
|
try {
|
||||||
const dashboard = await api("v1/actuators/dashboard/start");
|
const dashboard = await api("v1/actuators/dashboard/start");
|
||||||
@@ -670,17 +1126,18 @@ async function doLoadOverview() {
|
|||||||
if (budget) {
|
if (budget) {
|
||||||
const loadMs = dashboard._load_elapsed_ms;
|
const loadMs = dashboard._load_elapsed_ms;
|
||||||
budget.textContent = loadMs <= DASHBOARD_TIMEOUT_MS
|
budget.textContent = loadMs <= DASHBOARD_TIMEOUT_MS
|
||||||
? `Bereit in ${loadMs} ms`
|
? `Bereit in ${loadMs} ms`
|
||||||
: `Langsam: ${loadMs} ms`;
|
: `Langsam: ${loadMs} ms`;
|
||||||
|
budget.textContent = translateText(budget.textContent);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
document.getElementById("configured-actuators").innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
|
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 {
|
try {
|
||||||
await loadSummaryData();
|
await loadSummaryData();
|
||||||
renderConfiguredActuators();
|
renderConfiguredActuators();
|
||||||
} catch (_) {
|
} 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();
|
scheduleDashboardExtras();
|
||||||
@@ -697,7 +1154,7 @@ async function loadSystemOverview() {
|
|||||||
async function doLoadSystemOverview() {
|
async function doLoadSystemOverview() {
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
const budget = document.getElementById("load-budget");
|
const budget = document.getElementById("load-budget");
|
||||||
if (budget) budget.textContent = "Systemübersicht lädt ...";
|
if (budget) budget.textContent = ui("system_loading");
|
||||||
try {
|
try {
|
||||||
const dashboard = await api("v1/actuators/dashboard/system");
|
const dashboard = await api("v1/actuators/dashboard/system");
|
||||||
dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt);
|
dashboard._load_elapsed_ms = Math.round(performance.now() - startedAt);
|
||||||
@@ -709,11 +1166,12 @@ async function doLoadSystemOverview() {
|
|||||||
budget.textContent = loadMs <= DASHBOARD_TIMEOUT_MS
|
budget.textContent = loadMs <= DASHBOARD_TIMEOUT_MS
|
||||||
? `Systemübersicht bereit in ${loadMs} ms`
|
? `Systemübersicht bereit in ${loadMs} ms`
|
||||||
: `Systemübersicht langsam: ${loadMs} ms`;
|
: `Systemübersicht langsam: ${loadMs} ms`;
|
||||||
|
budget.textContent = translateText(budget.textContent);
|
||||||
}
|
}
|
||||||
scheduleDashboardExtras();
|
scheduleDashboardExtras();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
document.getElementById("status").innerHTML = `<p class="warn">Systemübersicht verzögert: ${escapeHtml(error.message)}</p>`;
|
document.getElementById("status").innerHTML = `<p class="warn">${escapeHtml(translateText(`Systemübersicht verzögert: ${error.message}`))}</p>`;
|
||||||
if (budget) budget.textContent = "Systemübersicht verzögert";
|
if (budget) budget.textContent = ui("system_delayed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -756,7 +1214,7 @@ async function loadDashboardExtras() {
|
|||||||
if (reconciliation.status === "fulfilled") {
|
if (reconciliation.status === "fulfilled") {
|
||||||
const text = document.getElementById("reconciliation-status");
|
const text = document.getElementById("reconciliation-status");
|
||||||
if (text) {
|
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 (_) {
|
} catch (_) {
|
||||||
@@ -793,6 +1251,8 @@ async function loadStatus() {
|
|||||||
status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
|
status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
|
||||||
chips.innerHTML = "";
|
chips.innerHTML = "";
|
||||||
}
|
}
|
||||||
|
localizeFragment(status);
|
||||||
|
localizeFragment(chips);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDashboardStatus(dashboard) {
|
function renderDashboardStatus(dashboard) {
|
||||||
@@ -857,6 +1317,9 @@ function renderDashboardStatus(dashboard) {
|
|||||||
`<div class="metric"><strong>Cache-Zeitpunkt</strong>${escapeHtml(cache.updated_at || "noch offen")}</div>`,
|
`<div class="metric"><strong>Cache-Zeitpunkt</strong>${escapeHtml(cache.updated_at || "noch offen")}</div>`,
|
||||||
].join("");
|
].join("");
|
||||||
renderJobQueue(jobs);
|
renderJobQueue(jobs);
|
||||||
|
localizeFragment(status);
|
||||||
|
localizeFragment(chips);
|
||||||
|
localizeFragment(stats);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderJobQueue(jobs) {
|
function renderJobQueue(jobs) {
|
||||||
@@ -877,6 +1340,7 @@ function renderJobQueue(jobs) {
|
|||||||
</div>
|
</div>
|
||||||
`).join("")}
|
`).join("")}
|
||||||
` : "";
|
` : "";
|
||||||
|
localizeFragment(jobsBox);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSummaryData() {
|
async function loadSummaryData() {
|
||||||
@@ -950,6 +1414,7 @@ function renderActuatorDiscovery() {
|
|||||||
options.innerHTML = "";
|
options.innerHTML = "";
|
||||||
select.innerHTML = `<option value="">Geräteliste konnte nicht geladen werden: ${escapeHtml(error.message)}</option>`;
|
select.innerHTML = `<option value="">Geräteliste konnte nicht geladen werden: ${escapeHtml(error.message)}</option>`;
|
||||||
}
|
}
|
||||||
|
localizeFragment(document.getElementById("choose"));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadActuatorSuggestions() {
|
async function loadActuatorSuggestions() {
|
||||||
@@ -976,6 +1441,7 @@ async function loadActuatorSuggestions() {
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
box.innerHTML = "";
|
box.innerHTML = "";
|
||||||
}
|
}
|
||||||
|
localizeFragment(box);
|
||||||
}
|
}
|
||||||
|
|
||||||
function actuatorGroupLabel(domain) {
|
function actuatorGroupLabel(domain) {
|
||||||
@@ -1023,6 +1489,7 @@ function renderActuatorSelect() {
|
|||||||
</optgroup>
|
</optgroup>
|
||||||
`),
|
`),
|
||||||
].join("");
|
].join("");
|
||||||
|
localizeFragment(select);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadContextOptions(actuatorId) {
|
async function loadContextOptions(actuatorId) {
|
||||||
@@ -1068,6 +1535,7 @@ function renderManualContextSelect() {
|
|||||||
select.innerHTML = filtered.length
|
select.innerHTML = filtered.length
|
||||||
? optionGroups(filtered, selectedNow)
|
? optionGroups(filtered, selectedNow)
|
||||||
: `<option value="">Keine passenden Vorschläge</option>`;
|
: `<option value="">Keine passenden Vorschläge</option>`;
|
||||||
|
localizeFragment(select);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseEntityIds(value) {
|
function parseEntityIds(value) {
|
||||||
@@ -1165,6 +1633,7 @@ function renderConfiguredActuators() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
box.textContent = error.message;
|
box.textContent = error.message;
|
||||||
}
|
}
|
||||||
|
localizeFragment(box);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showActuator(actuatorId, evaluationMessage = "") {
|
async function showActuator(actuatorId, evaluationMessage = "") {
|
||||||
@@ -1177,6 +1646,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|||||||
const box = document.getElementById("actuator-detail");
|
const box = document.getElementById("actuator-detail");
|
||||||
if (!evaluationMessage && cachedDetailHtml.has(actuatorId)) {
|
if (!evaluationMessage && cachedDetailHtml.has(actuatorId)) {
|
||||||
box.innerHTML = cachedDetailHtml.get(actuatorId);
|
box.innerHTML = cachedDetailHtml.get(actuatorId);
|
||||||
|
localizeFragment(box);
|
||||||
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
|
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
|
||||||
renderConfiguredActuators();
|
renderConfiguredActuators();
|
||||||
return;
|
return;
|
||||||
@@ -1553,12 +2023,14 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|||||||
${manualAssignment}
|
${manualAssignment}
|
||||||
`;
|
`;
|
||||||
box.innerHTML = detailHtml;
|
box.innerHTML = detailHtml;
|
||||||
cachedDetailHtml.set(actuatorId, detailHtml);
|
localizeFragment(box);
|
||||||
|
cachedDetailHtml.set(actuatorId, box.innerHTML);
|
||||||
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
|
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
|
||||||
renderConfiguredActuators();
|
renderConfiguredActuators();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
box.textContent = error.message;
|
box.textContent = error.message;
|
||||||
}
|
}
|
||||||
|
localizeFragment(box);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderActuatorDetailShell(actuatorId) {
|
function renderActuatorDetailShell(actuatorId) {
|
||||||
@@ -1575,6 +2047,7 @@ function renderActuatorDetailShell(actuatorId) {
|
|||||||
<div class="metric"><strong>Kontext</strong>...</div>
|
<div class="metric"><strong>Kontext</strong>...</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
localizeFragment(document.getElementById("actuator-detail"));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hydrateContextOptions(record) {
|
async function hydrateContextOptions(record) {
|
||||||
@@ -1602,6 +2075,8 @@ async function hydrateContextOptions(record) {
|
|||||||
${contextCategories.map(category => `<option value="${escapeHtml(category)}">${escapeHtml(category)}</option>`).join("")}
|
${contextCategories.map(category => `<option value="${escapeHtml(category)}">${escapeHtml(category)}</option>`).join("")}
|
||||||
`;
|
`;
|
||||||
renderManualContextSelect();
|
renderManualContextSelect();
|
||||||
|
localizeFragment(numericSelect);
|
||||||
|
localizeFragment(categorySelect);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hydrateCurrentContextOptions(actuatorId) {
|
async function hydrateCurrentContextOptions(actuatorId) {
|
||||||
@@ -1703,6 +2178,7 @@ async function simulateActuator(actuatorId) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
box.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
|
box.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
|
||||||
}
|
}
|
||||||
|
localizeFragment(box);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applySimulationWeights(actuatorId, scenarioId, startDryRun) {
|
async function applySimulationWeights(actuatorId, scenarioId, startDryRun) {
|
||||||
@@ -1950,9 +2426,11 @@ async function removeActuator(actuatorId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startDashboard() {
|
async function startDashboard() {
|
||||||
document.getElementById("status").innerHTML = "<p class='muted'>Status lädt nach ...</p>";
|
document.documentElement.lang = uiLang;
|
||||||
document.getElementById("configured-actuators").innerHTML = "<div class='empty-state'>Öffne „Lernen“, um Geräte zu laden.</div>";
|
applyStaticTranslations();
|
||||||
document.getElementById("actuator-detail").innerHTML = "<div class='empty-state'>Wähle später ein Gerät aus der Übersicht.</div>";
|
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();
|
syncSettingsView();
|
||||||
const initialView = localStorage.getItem("sillyhome.ui.view") === "detail"
|
const initialView = localStorage.getItem("sillyhome.ui.view") === "detail"
|
||||||
? "observed"
|
? "observed"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "sillyhome-next"
|
name = "sillyhome-next"
|
||||||
version = "1.7.3"
|
version = "1.7.5"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ def _service(
|
|||||||
|
|
||||||
|
|
||||||
def test_reconciliation_auto_assigns_and_trains_numeric_model(tmp_path: Path) -> None:
|
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 = [
|
entities = [
|
||||||
HaEntitySummary(
|
HaEntitySummary(
|
||||||
entity_id="light.abstellkammer",
|
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"
|
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:
|
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 = [
|
||||||
|
|||||||
@@ -646,6 +646,81 @@ def test_prediction_ignores_stale_causal_context_state() -> None:
|
|||||||
) is 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(
|
def test_state_change_uses_websocket_context_state_for_immediate_action(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -120,6 +120,28 @@ def test_normalize_state_history_keeps_categorical_changes() -> None:
|
|||||||
assert [point.state for point in result[0].points] == ["off", "on"]
|
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:
|
def test_normalize_logbook_preserves_action_origin() -> None:
|
||||||
result = normalize_logbook_payload(
|
result = normalize_logbook_payload(
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in New Issue
Block a user