Compare commits

..

8 Commits
v1.7.1 ... main

Author SHA1 Message Date
81b2327e84 Filter room management maintenance actions
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-07-26 23:18:19 +02:00
1788f9d963 Expand room planning management
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-07-26 23:12:49 +02:00
9ff005f089 Add room management settings
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-07-26 22:44:56 +02:00
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
17 changed files with 2205 additions and 65 deletions

View File

@@ -1,5 +1,61 @@
# Changelog
## 1.7.8 - 2026-07-26
- Raumverwaltung blendet Wartungs-/Diagnose-Aktoren wie Batterie-Reset,
Ping, Identify, Restart/Reboot/Reload und Wake-on-LAN aus den
Raumvorschlägen aus.
- Dadurch bleiben Räume auf nutzbare Steuerungen fokussiert: Licht, Strom,
Schalter, Steckdosen, Heizung, Wasser, Belüftung, Sicherheit, Rollos und
echte Szenen/Regler.
## 1.7.7 - 2026-07-26
- Raumverwaltung erzeugt jetzt eine vollständige Übersicht aus allen
Home-Assistant-Bereichen, nicht nur aus bereits konfigurierten Aktoren.
- Räume zeigen Sensoren, unverwaltete Aktoren und passende Handlungs-
Vorschläge für Licht, Strom, Schalter, Heizung, Wasser, Belüftung,
Sicherheit, Rollos und weitere steuerbare Geräte.
- Jede vorgeschlagene Handlung liefert Bedingung, Aktion, Begründung,
Sicherheit und Lernbarkeit, damit klar ist, was wann warum eintreten könnte.
- Startup- und geplante Reconciliation aktualisieren nun auch Evaluation und
Planungs-Insights kontinuierlich.
## 1.7.6 - 2026-07-26
- Einstellungen um eine Raumverwaltung erweitert: Räume zeigen Aktoren,
aktive/optionale/nicht nötige Sensoren und lesbare Vorhersage-Regeln in
einer gemeinsamen Ansicht.
- Neue API `/v1/actuators/settings/rooms` liefert kompakte Verwaltungsdaten
für Raumkarten, Sensorvorschläge, Aktoren und noch nicht verwaltete
Vorschläge.
- Licht-/Schalter-Zuordnung darf bei eindeutigem Tür-/Öffnungskontext ohne
numerischen Helligkeitssensor arbeiten, z. B. Tür auf -> Licht an und Tür zu
-> Licht aus.
## 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.1"
version: "1.7.8"
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:
@@ -517,6 +546,25 @@ class ActuatorReconciliationService:
if candidate.auto_accepted
][: _MAX_CONTEXT_SELECTIONS]
top_contexts = [candidate.entity_id for candidate in accepted_contexts]
if (
top_numeric is not None
and actuator.domain in {"light", "switch"}
and any(
(candidate.device_class or "") in {"door", "garage_door", "opening", "window"}
for candidate in accepted_contexts
)
):
return AssignmentSelection(
selected_numeric_entity_id=None,
selected_context_entity_ids=top_contexts,
source=AssignmentSource.AUTOMATIC,
confidence=max(candidate.confidence for candidate in accepted_contexts),
review_required=False,
reason=(
"Tür-/Öffnungskontext automatisch erkannt. Für diese "
"direkte Schaltlogik ist kein Helligkeitssensor erforderlich."
),
)
if top_numeric is None:
if accepted_contexts:
return AssignmentSelection(
@@ -864,6 +912,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 +938,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 +971,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 +997,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 +1166,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

View File

@@ -13,6 +13,8 @@ from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import (
ActuatorRecord,
AnomalyEvent,
AssignmentCandidate,
BehaviorPattern,
FeedbackKind,
ReconciliationState,
SensorWeightGroup,
@@ -177,6 +179,67 @@ class AnomalyOverview(BaseModel):
anomalies: list[AnomalyEvent] = Field(default_factory=list)
class RoomManagementSensor(BaseModel):
entity_id: str
domain: str
role: str
category: str
friendly_name: str | None = None
device_class: str | None = None
state: str | None = None
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
active: bool = False
optional: bool = False
not_required: bool = False
reason: str
class RoomManagementAction(BaseModel):
action_id: str
category: str
actuator_entity_id: str
title: str
when: str
then: str
why: str
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
learnable: bool = True
sort_key: str = ""
class RoomManagementActuator(BaseModel):
actuator_entity_id: str
friendly_name: str | None = None
domain: str
behavior_mode: str
behavior_status: str
lifecycle_status: str
sample_count: int = 0
selected_numeric_entity_id: str | None = None
selected_context_entity_ids: list[str] = Field(default_factory=list)
sensors: list[RoomManagementSensor] = Field(default_factory=list)
prediction_rules: list[str] = Field(default_factory=list)
suggested_actions: list[RoomManagementAction] = Field(default_factory=list)
management_hint: str
class RoomManagementGroup(BaseModel):
room: str
actuator_count: int
sensor_count: int = 0
action_count: int = 0
sensors: list[RoomManagementSensor] = Field(default_factory=list)
actuators: list[RoomManagementActuator] = Field(default_factory=list)
prediction_rules: list[str] = Field(default_factory=list)
suggested_actions: list[RoomManagementAction] = Field(default_factory=list)
continuous_hint: str = "Wird bei Discovery, Reconciliation und Lernrefresh automatisch neu bewertet."
class RoomManagementOverview(BaseModel):
rooms: list[RoomManagementGroup] = Field(default_factory=list)
unmanaged_actuators: list[ActuatorSuggestion] = Field(default_factory=list)
@router.get("/discovery", response_model=list[HaEntitySummary])
def discover_actuators(
request: Request,
@@ -444,6 +507,151 @@ def list_anomalies(request: Request) -> list[AnomalyOverview]:
return overview
@router.get("/settings/rooms", response_model=RoomManagementOverview)
def room_management_overview(request: Request) -> RoomManagementOverview:
service = _service(request)
records = service.list_configured()
try:
entities = {entity.entity_id: entity for entity in service._ha_reader.read_entities()}
except Exception:
entities = _load_cached_entity_map(
request,
{
entity_id
for record in records
for entity_id in [
record.actuator_entity_id,
record.assignment.selected_numeric_entity_id,
*record.assignment.selected_context_entity_ids,
*[candidate.entity_id for candidate in record.numeric_candidates[:8]],
*[candidate.entity_id for candidate in record.context_candidates[:12]],
]
if entity_id
},
)
discovered = {entity.entity_id: entity for entity in discover_entities(list(entities.values()))}
configured_ids = {record.actuator_entity_id for record in records}
rooms = _build_room_shells(entities, discovered)
for record in records:
actuator = entities.get(record.actuator_entity_id)
room = (
actuator.area_name
if actuator is not None and actuator.area_name
else _candidate_room(record)
) or "Ohne Raum"
selected_context_ids = set(record.assignment.selected_context_entity_ids)
selected_numeric_id = record.assignment.selected_numeric_entity_id
selected_ids = {selected_numeric_id, *selected_context_ids} - {None}
ranked_candidates = _rank_management_candidates(record)
has_opening_context = any(
candidate.entity_id in selected_context_ids
and (candidate.device_class or "") in {"door", "garage_door", "opening", "window"}
for candidate in ranked_candidates
)
sensors = [
_management_sensor(
candidate,
entities.get(candidate.entity_id),
active=candidate.entity_id in selected_ids,
optional=(
candidate.role is EntityRole.MEASUREMENT
and candidate.entity_id != selected_numeric_id
),
not_required=(
record.actuator_entity_id.startswith(("light.", "switch."))
and has_opening_context
and (candidate.device_class or "") == "illuminance"
),
)
for candidate in ranked_candidates[:12]
]
actuator_group = RoomManagementActuator(
actuator_entity_id=record.actuator_entity_id,
friendly_name=actuator.friendly_name if actuator is not None else None,
domain=record.actuator_entity_id.split(".", 1)[0],
behavior_mode=record.behavior.mode.value,
behavior_status=record.behavior.status.value,
lifecycle_status=record.lifecycle.status.value,
sample_count=record.behavior.sample_count,
selected_numeric_entity_id=selected_numeric_id,
selected_context_entity_ids=record.assignment.selected_context_entity_ids,
sensors=sensors,
prediction_rules=_prediction_rule_lines(record, ranked_candidates),
suggested_actions=_suggest_room_actions(
actuator_id=record.actuator_entity_id,
domain=record.actuator_entity_id.split(".", 1)[0],
sensors=sensors,
configured=True,
),
management_hint=_management_hint(record, has_opening_context),
)
if room not in rooms:
rooms[room] = RoomManagementGroup(room=room, actuator_count=0)
rooms[room].actuators.append(actuator_group)
rooms[room].actuator_count += 1
rooms[room].prediction_rules = _unique_lines([
*rooms[room].prediction_rules,
*actuator_group.prediction_rules,
])[:8]
rooms[room].sensors = _merge_room_sensors(rooms[room].sensors, sensors)
rooms[room].suggested_actions = _merge_room_actions(
rooms[room].suggested_actions,
actuator_group.suggested_actions,
)
for entity_id, descriptor in discovered.items():
if descriptor.role is not EntityRole.ACTUATOR or entity_id in configured_ids:
continue
actuator = entities.get(entity_id)
if actuator is None:
continue
if not _is_management_actuator(actuator):
continue
room = _entity_room(actuator)
if room not in rooms:
rooms[room] = RoomManagementGroup(room=room, actuator_count=0)
room_sensors = _room_sensors_for_actuator(actuator, rooms[room].sensors)
actions = _suggest_room_actions(
actuator_id=entity_id,
domain=actuator.domain,
sensors=room_sensors,
configured=False,
)
rooms[room].actuators.append(
RoomManagementActuator(
actuator_entity_id=entity_id,
friendly_name=actuator.friendly_name,
domain=actuator.domain,
behavior_mode="unmanaged",
behavior_status="suggested",
lifecycle_status="unconfigured",
sensors=room_sensors,
prediction_rules=[_action_rule_line(action) for action in actions[:5]],
suggested_actions=actions,
management_hint=(
"Noch nicht verwaltet: übernehmen, wenn diese Handlung gelernt oder vorgeschlagen werden soll."
),
)
)
rooms[room].actuator_count += 1
rooms[room].prediction_rules = _unique_lines([
*rooms[room].prediction_rules,
*[_action_rule_line(action) for action in actions],
])[:8]
rooms[room].suggested_actions = _merge_room_actions(rooms[room].suggested_actions, actions)
for room in rooms.values():
room.sensors = _merge_room_sensors([], room.sensors)
room.sensor_count = len(room.sensors)
room.action_count = len(room.suggested_actions)
unmanaged = [
suggestion for suggestion in suggest_actuators(request, service._ha_reader)
if suggestion.entity_id not in configured_ids
][:10]
return RoomManagementOverview(
rooms=sorted(rooms.values(), key=lambda item: item.room.lower()),
unmanaged_actuators=unmanaged,
)
@router.get("/backup/export", response_model=BackupPayload)
def export_backup(request: Request) -> BackupPayload:
store = getattr(request.app.state, "actuator_store", None)
@@ -922,6 +1130,484 @@ def _validate_weight_payload(payload: WeightOverrideRequest) -> None:
raise ValueError(f"Ungültige Entity-ID in Gruppe {group.name}: {entity_id}")
def _build_room_shells(
entities: dict[str, HaEntitySummary],
discovered: dict[str, DiscoveredEntity],
) -> dict[str, RoomManagementGroup]:
rooms: dict[str, RoomManagementGroup] = {}
for entity in entities.values():
room = _entity_room(entity)
if room not in rooms:
rooms[room] = RoomManagementGroup(room=room, actuator_count=0)
descriptor = discovered.get(entity.entity_id)
if descriptor is None or descriptor.role is EntityRole.ACTUATOR:
continue
sensor = _entity_management_sensor(entity, descriptor)
if sensor is not None:
rooms[room].sensors = _merge_room_sensors(rooms[room].sensors, [sensor])
return rooms
def _entity_room(entity: HaEntitySummary) -> str:
room = entity.area_name or _room_from_text(entity.friendly_name or entity.device_name or entity.entity_id)
return room or "Ohne Raum"
def _is_management_actuator(entity: HaEntitySummary) -> bool:
if entity.domain not in {
"climate",
"cover",
"fan",
"humidifier",
"input_boolean",
"light",
"lock",
"number",
"scene",
"siren",
"switch",
"valve",
}:
return False
text = " ".join(
str(value).lower().replace("_", " ")
for value in [entity.entity_id, entity.friendly_name, entity.device_name]
if value
)
noisy_tokens = {
"battery replaced",
"identify",
"ping",
"reboot",
"reload",
"restart",
"wake on lan",
}
if any(token in text for token in noisy_tokens):
return False
return True
def _room_from_text(value: str) -> str | None:
normalized = value.replace("_", " ").replace("-", " ").strip()
if not normalized:
return None
known_rooms = {
"abstellkammer": "Abstellkammer",
"abstellraum": "Abstellkammer",
"bad": "Bad",
"badezimmer": "Bad",
"buro": "Büro",
"buero": "Büro",
"flur": "Flur",
"gaeste wc": "Gäste WC",
"gaste wc": "Gäste WC",
"keller": "Keller",
"kuche": "Küche",
"kueche": "Küche",
"schlafzimmer": "Schlafzimmer",
"terrasse": "Terrasse",
"wohnbereich": "Wohnbereich",
"wohnzimmer": "Wohnbereich",
}
lowered = normalized.lower()
for token, room in known_rooms.items():
if token in lowered:
return room
return None
def _entity_management_sensor(
entity: HaEntitySummary,
descriptor: DiscoveredEntity,
) -> RoomManagementSensor | None:
if descriptor.role not in {EntityRole.MEASUREMENT, EntityRole.BINARY_CONTEXT, EntityRole.CONTEXT}:
return None
candidate = AssignmentCandidate(
entity_id=entity.entity_id,
domain=entity.domain,
role=descriptor.role,
device_class=entity.device_class,
state_class=entity.state_class,
unit_of_measurement=entity.unit_of_measurement,
friendly_name=entity.friendly_name,
area_name=entity.area_name,
device_name=entity.device_name,
score=0.55,
confidence=0.55,
evidence=["Gehört laut Home Assistant zu diesem Bereich."],
)
return _management_sensor(
candidate,
entity,
active=False,
optional=descriptor.role is EntityRole.MEASUREMENT,
not_required=False,
)
def _room_sensors_for_actuator(
actuator: HaEntitySummary,
sensors: list[RoomManagementSensor],
) -> list[RoomManagementSensor]:
preferred = _preferred_sensor_categories(actuator.domain)
ranked = sorted(
sensors,
key=lambda sensor: (
sensor.category not in preferred,
preferred.index(sensor.category) if sensor.category in preferred else 99,
-sensor.confidence,
sensor.friendly_name or sensor.entity_id,
),
)
return ranked[:12]
def _preferred_sensor_categories(domain: str) -> list[str]:
mapping = {
"climate": ["Temperatur", "Luftfeuchtigkeit", "Tür/Fenster", "Präsenz", "Energie"],
"cover": ["Helligkeit", "Präsenz", "Tür/Fenster", "Temperatur"],
"fan": ["Luftfeuchtigkeit", "Präsenz", "Temperatur", "Tür/Fenster", "Energie"],
"humidifier": ["Luftfeuchtigkeit", "Temperatur", "Präsenz"],
"light": ["Präsenz", "Tür/Fenster", "Helligkeit", "Zone/Person"],
"lock": ["Tür/Fenster", "Präsenz", "Zone/Person"],
"siren": ["Sicherheit", "Tür/Fenster", "Präsenz"],
"switch": ["Präsenz", "Tür/Fenster", "Energie", "Luftfeuchtigkeit", "Helligkeit"],
"valve": ["Wasser", "Luftfeuchtigkeit", "Temperatur", "Tür/Fenster"],
}
return mapping.get(domain, ["Präsenz", "Tür/Fenster", "Energie", "Kontext"])
def _candidate_room(record: ActuatorRecord) -> str | None:
for candidate in [*record.context_candidates, *record.numeric_candidates]:
if candidate.area_name:
return candidate.area_name
return None
def _rank_management_candidates(record: ActuatorRecord) -> list[AssignmentCandidate]:
selected_ids = {
entity_id
for entity_id in [
record.assignment.selected_numeric_entity_id,
*record.assignment.selected_context_entity_ids,
]
if entity_id
}
candidates = {
candidate.entity_id: candidate
for candidate in [*record.context_candidates, *record.numeric_candidates]
}
ranked = sorted(
candidates.values(),
key=lambda item: (
item.entity_id not in selected_ids,
_management_sort_group(item),
-item.confidence,
-item.score,
item.entity_id,
),
)
return ranked
def _management_sort_group(candidate: AssignmentCandidate) -> str:
device_class = candidate.device_class or ""
if device_class in {"door", "garage_door", "opening", "window"}:
return "01_opening"
if device_class in {"motion", "occupancy", "presence"}:
return "02_presence"
if device_class == "illuminance":
return "03_brightness"
if device_class in {"humidity", "moisture"}:
return "04_humidity"
if candidate.role is EntityRole.MEASUREMENT:
return "08_measurement"
return f"20_{candidate.domain}_{device_class}"
def _management_sensor(
candidate: AssignmentCandidate,
entity: HaEntitySummary | None,
*,
active: bool,
optional: bool,
not_required: bool,
) -> RoomManagementSensor:
if not_required:
reason = "Nicht nötig, weil ein Tür-/Öffnungskontakt die Lichtlogik direkt erklärt."
elif active:
reason = "Wird aktuell für Lernen und Vorhersage verwendet."
elif optional:
reason = "Optionaler Messwert; nur verwenden, wenn Helligkeit oder Verbrauch wirklich steuern soll."
else:
reason = ", ".join(candidate.evidence[:2]) or "Naheliegender Kontext aus Raum, Gerät oder Namen."
return RoomManagementSensor(
entity_id=candidate.entity_id,
domain=candidate.domain,
role=candidate.role.value,
category=_sensor_category_label(candidate),
friendly_name=candidate.friendly_name,
device_class=candidate.device_class,
state=entity.state if entity is not None else None,
confidence=candidate.confidence,
active=active,
optional=optional,
not_required=not_required,
reason=reason,
)
def _sensor_category_label(candidate: AssignmentCandidate) -> str:
device_class = candidate.device_class or ""
if device_class in {"door", "garage_door", "opening", "window"}:
return "Tür/Fenster"
if device_class in {"motion", "occupancy", "presence"}:
return "Präsenz"
if device_class == "illuminance":
return "Helligkeit"
if device_class in {"humidity", "moisture"}:
return "Luftfeuchtigkeit"
if device_class == "temperature":
return "Temperatur"
if device_class in {"power", "energy", "current", "voltage"}:
return "Energie"
if device_class in {"gas", "water"} or candidate.unit_of_measurement in {"m3", "L", "l"}:
return "Wasser"
if device_class in {"problem", "safety", "smoke", "vibration"}:
return "Sicherheit"
if candidate.domain in {"cover"}:
return "Rollo/Cover"
if candidate.domain in {"zone", "person", "device_tracker"}:
return "Zone/Person"
return "Kontext"
def _merge_room_sensors(
existing: list[RoomManagementSensor],
incoming: list[RoomManagementSensor],
) -> list[RoomManagementSensor]:
by_id = {sensor.entity_id: sensor for sensor in existing}
for sensor in incoming:
current = by_id.get(sensor.entity_id)
if current is None:
by_id[sensor.entity_id] = sensor
continue
by_id[sensor.entity_id] = current.model_copy(
update={
"active": current.active or sensor.active,
"optional": current.optional and sensor.optional,
"not_required": current.not_required and sensor.not_required,
"confidence": max(current.confidence, sensor.confidence),
}
)
return sorted(
by_id.values(),
key=lambda item: (
not item.active,
item.not_required,
item.category,
item.friendly_name or item.entity_id,
),
)[:18]
def _prediction_rule_lines(
record: ActuatorRecord,
candidates: list[AssignmentCandidate],
) -> list[str]:
lines = _pattern_rule_lines(record.behavior.patterns)
if lines:
return lines[:8]
selected_contexts = [
candidate
for candidate in candidates
if candidate.entity_id in set(record.assignment.selected_context_entity_ids)
]
result: list[str] = []
for candidate in selected_contexts:
label = candidate.friendly_name or candidate.entity_id
device_class = candidate.device_class or ""
if device_class in {"door", "garage_door", "opening", "window"}:
result.extend([
f"{label} geöffnet -> {record.actuator_entity_id} an.",
f"{label} geschlossen -> {record.actuator_entity_id} aus.",
])
elif device_class in {"motion", "occupancy", "presence"}:
result.extend([
f"{label} erkannt -> {record.actuator_entity_id} an, bei Licht bevorzugt gedimmt.",
f"{label} aus -> {record.actuator_entity_id} verzögert ausschalten.",
])
elif device_class in {"humidity", "moisture"}:
result.append(f"{label} hoch -> {record.actuator_entity_id} einschalten, bis Feuchte wieder normal ist.")
if record.assignment.selected_numeric_entity_id:
result.append(
f"{record.assignment.selected_numeric_entity_id} nur als Messwert verwenden, nicht als Pflichtsensor."
)
return _unique_lines(result)[:8] or ["Noch keine stabile Vorhersage; erst Kontext prüfen und weiter beobachten."]
def _suggest_room_actions(
*,
actuator_id: str,
domain: str,
sensors: list[RoomManagementSensor],
configured: bool,
) -> list[RoomManagementAction]:
sensor_categories = {sensor.category for sensor in sensors}
sensor_labels = {
sensor.category: sensor.friendly_name or sensor.entity_id
for sensor in sensors
}
confidence_base = 0.78 if configured else 0.58
actions: list[RoomManagementAction] = []
def add(category: str, title: str, when: str, then: str, why: str, confidence: float) -> None:
actions.append(
RoomManagementAction(
action_id=f"{actuator_id}:{category}:{len(actions)}",
category=category,
actuator_entity_id=actuator_id,
title=title,
when=when,
then=then,
why=why,
confidence=round(min(1.0, confidence), 4),
learnable=True,
sort_key=f"{category}:{actuator_id}:{len(actions):02d}",
)
)
presence = sensor_labels.get("Präsenz")
opening = sensor_labels.get("Tür/Fenster")
brightness = sensor_labels.get("Helligkeit")
humidity = sensor_labels.get("Luftfeuchtigkeit")
temperature = sensor_labels.get("Temperatur")
energy = sensor_labels.get("Energie")
water = sensor_labels.get("Wasser")
safety = sensor_labels.get("Sicherheit")
zone = sensor_labels.get("Zone/Person")
if domain == "light":
if opening:
add("licht", "Türlicht", f"{opening} öffnet oder schließt", "Licht passend an/aus schalten.", "Türkontakt erklärt kleine Räume ohne Helligkeitssensor.", confidence_base + 0.12)
if presence:
when = f"{presence} erkennt Anwesenheit"
if brightness:
when += f" und {brightness} ist dunkel"
add("licht", "Präsenzlicht", when, "Licht gedimmt einschalten und bei Abwesenheit verzögert ausschalten.", "Anwesenheit plus Helligkeit vermeidet unnötiges Licht.", confidence_base + (0.12 if brightness else 0.04))
if zone:
add("licht", "Zonenstimmung", f"{zone} wird betreten oder verlassen", "Beim Betreten dimmen, beim Aufstehen heller/weiß stellen und später vorherige Stimmung wiederherstellen.", "Zonen wie Sofa brauchen andere Helligkeit als Durchgang oder Aktivität.", confidence_base)
elif domain in {"switch", "input_boolean"}:
if energy:
add("strom", "Verbrauchssteuerung", f"{energy} zeigt Standby oder Last", "Steckdose/Schalter bei Bedarf schalten oder Standby reduzieren.", "Stromwerte zeigen, ob ein Verbraucher wirklich gebraucht wird.", confidence_base + 0.1)
if presence:
add("strom", "Anwesenheitsschalter", f"{presence} aus", "Verbraucher verzögert ausschalten.", "Schalter und Steckdosen sollen Räume nicht unnötig versorgen.", confidence_base)
if opening:
add("schalter", "Kontaktlogik", f"{opening} wechselt", "Schalter passend zum Öffnen/Schließen setzen.", "Kontaktzustände sind direkte, leicht prüfbare Auslöser.", confidence_base)
elif domain == "climate":
if temperature:
add("heizung", "Temperaturregelung", f"{temperature} weicht vom Ziel ab", "Heizung nach Lernprofil anpassen.", "Temperaturverlauf und Anwesenheit erklären Heizbedarf.", confidence_base + 0.12)
if opening:
add("heizung", "Fenster-Offen-Schutz", f"{opening} offen", "Heizung pausieren oder Sollwert senken.", "Offene Fenster/Türen sollen nicht gegen die Heizung arbeiten.", confidence_base + 0.1)
if presence:
add("heizung", "Anwesenheitswärme", f"{presence} an/aus", "Komforttemperatur nur bei Nutzung halten.", "Anwesenheit macht Heizprofile einfacher und sparsamer.", confidence_base)
elif domain in {"fan", "humidifier"}:
if humidity:
add("belueftung", "Feuchteführung", f"{humidity} steigt oder bleibt hoch", "Lüftung/Entfeuchtung einschalten, später zurücknehmen.", "Feuchtigkeit ist der wichtigste Kontext für Lüftung.", confidence_base + 0.16)
if presence:
add("belueftung", "Nutzungsabhängige Lüftung", f"{presence} aktiv", "Lüftung leise/bedarfsgerecht führen.", "Nutzung erklärt Gerüche, Feuchte und Komfort.", confidence_base)
elif domain == "cover":
if brightness:
add("rollo", "Sonnen-/Dunkellogik", f"{brightness} sehr hell oder dunkel", "Rollo passend beschatten oder öffnen.", "Helligkeit steuert Blendung, Wärme und Tageslicht.", confidence_base + 0.12)
if presence:
add("rollo", "Privatsphäre", f"{presence} und Abend/Dunkelheit", "Rollo für Privatsphäre schließen.", "Anwesenheit und Lichtlage erklären Rollo-Bedarf.", confidence_base)
elif domain in {"valve"}:
if water or humidity:
add("wasser", "Wasser-/Leckschutz", f"{water or humidity} auffällig", "Ventil schließen oder Sperre vorschlagen.", "Wasser- und Feuchtesensoren sind Sicherheitskontext.", confidence_base + 0.14)
elif domain in {"lock", "siren"}:
if opening or safety:
add("sicherheit", "Sicherheitszustand", f"{opening or safety} meldet Änderung", "Sicherheitsaktion vorschlagen, aber nicht ohne Freigabe aktiv ausführen.", "Sicherheitsaktionen brauchen hohe Sicherheit und klare Erklärung.", confidence_base)
if not actions:
add(
domain,
"Allgemeine Lernregel",
"passende Sensoren in diesem Raum ändern sich",
"Aktor im Shadow-Modus beobachten und Vorschläge sammeln.",
"Noch fehlen eindeutige Kontextsensoren; Discovery prüft den Raum weiter.",
max(0.35, confidence_base - 0.18),
)
return sorted(actions, key=lambda item: (-item.confidence, item.sort_key))[:8]
def _merge_room_actions(
existing: list[RoomManagementAction],
incoming: list[RoomManagementAction],
) -> list[RoomManagementAction]:
by_key = {action.action_id: action for action in existing}
for action in incoming:
current = by_key.get(action.action_id)
if current is None or action.confidence > current.confidence:
by_key[action.action_id] = action
return sorted(by_key.values(), key=lambda item: (-item.confidence, item.sort_key))[:18]
def _action_rule_line(action: RoomManagementAction) -> str:
return f"{action.when} -> {action.then}"
def _pattern_rule_lines(patterns: list[BehaviorPattern]) -> list[str]:
buckets: dict[tuple[str, tuple[tuple[str, str], ...]], int] = {}
attrs: dict[tuple[str, tuple[tuple[str, str], ...]], dict[str, object]] = {}
for pattern in patterns[-120:]:
context = tuple(sorted(pattern.context_states.items()))
key = (pattern.target_state, context)
buckets[key] = buckets.get(key, 0) + 1
attrs[key] = pattern.target_attributes
ordered = sorted(buckets.items(), key=lambda item: (-item[1], item[0]))
lines: list[str] = []
for (target_state, context), count in ordered[:8]:
conditions = ", ".join(f"{entity}={state}" for entity, state in context[:3])
if not conditions:
conditions = "aktueller Zeit-/Nutzungskontext passt"
attr_text = _attribute_text(attrs.get((target_state, context), {}))
lines.append(f"{conditions} -> {target_state}{attr_text} ({count}x gelernt).")
return lines
def _attribute_text(attributes: dict[str, object]) -> str:
if not attributes:
return ""
brightness = attributes.get("brightness")
if isinstance(brightness, int | float):
percent = round(max(0, min(255, float(brightness))) / 255 * 100)
return f", Helligkeit {percent} %"
return ""
def _management_hint(record: ActuatorRecord, has_opening_context: bool) -> str:
if has_opening_context and record.actuator_entity_id.startswith(("light.", "switch.")):
return "Direkte Türlogik: kein Helligkeitssensor nötig, Sensor und Aktor reichen."
if record.behavior.activation_ready:
return "Regeln sind lernbereit; vor Aktivierung Vorhersagen prüfen."
if record.assignment.review_required:
return "Kontext prüfen: Vorschläge übernehmen oder unpassende Sensoren entfernen."
return "Weiter beobachten, bis genug eindeutige Schaltbeispiele vorhanden sind."
def _unique_lines(lines: list[str]) -> list[str]:
seen: set[str] = set()
result: list[str] = []
for line in lines:
normalized = line.strip()
if not normalized or normalized in seen:
continue
seen.add(normalized)
result.append(normalized)
return result
def _validate_simulation_payload(payload: SimulationRequest) -> None:
for entity_id in [
*payload.sensor_states.keys(),

View File

@@ -48,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__)
@@ -339,7 +356,7 @@ class BehaviorEngine:
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:
@@ -439,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:
@@ -1107,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,
@@ -1150,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,
@@ -1941,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:
@@ -1954,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 = [
@@ -1969,6 +1999,9 @@ def predict_behavior(
)
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
)
@@ -1998,6 +2031,9 @@ def predict_behavior(
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(
@@ -2011,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,
@@ -2044,9 +2083,87 @@ def _weighted_context_score(
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":
@@ -2112,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)
@@ -2131,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.1",
version="1.7.8",
lifespan=lifespan,
)
app.state.settings = load_settings()
@@ -167,6 +167,8 @@ async def _periodic_reconciliation(app: FastAPI) -> None:
engine = getattr(app.state, "behavior_engine", None)
if isinstance(engine, BehaviorEngine):
await asyncio.to_thread(engine.train_all)
await asyncio.to_thread(engine.evaluate_all)
await asyncio.to_thread(engine.refresh_planning_insights)
except Exception:
logger.exception("Geplante Reconciliation fehlgeschlagen; nächster Lauf versucht es erneut.")
@@ -221,6 +223,7 @@ async def _startup_reconciliation(app: FastAPI) -> None:
await asyncio.to_thread(service.reconcile_all, "startup")
await asyncio.to_thread(engine.train_all)
await asyncio.to_thread(engine.evaluate_all)
await asyncio.to_thread(engine.refresh_planning_insights)
logger.info("Startup-Reconciliation erfolgreich abgeschlossen.")
return
except Exception as exc:
@@ -255,6 +258,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 +289,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 +318,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 +343,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 +375,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 +411,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(

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "sillyhome-next"
version = "1.7.1"
version = "1.7.8"
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",
@@ -141,6 +141,46 @@ def test_reconciliation_auto_assigns_and_trains_numeric_model(tmp_path: Path) ->
assert "binary_sensor.abstellkammer_motion" not in artifact.supported_sensors
def test_light_with_opening_context_does_not_require_brightness_sensor(tmp_path: Path) -> None:
start = datetime.now(timezone.utc) - timedelta(days=1)
entities = [
HaEntitySummary(
entity_id="light.abstellkammer",
domain="light",
friendly_name="Abstellkammer Licht",
area_name="Abstellkammer",
),
HaEntitySummary(
entity_id="sensor.abstellkammer_illuminance",
domain="sensor",
device_class="illuminance",
state_class="measurement",
unit_of_measurement="lx",
friendly_name="Abstellkammer Helligkeit",
area_name="Abstellkammer",
),
HaEntitySummary(
entity_id="binary_sensor.abstellkammer_tuer",
domain="binary_sensor",
device_class="door",
friendly_name="Tür Abstellkammer",
area_name="Abstellkammer",
),
]
service = _service(
tmp_path,
entities,
{"sensor.abstellkammer_illuminance": _points(8, start, 10.0)},
)
record = service.configure_actuator("light.abstellkammer")
assert record.assignment.selected_numeric_entity_id is None
assert record.assignment.selected_context_entity_ids == ["binary_sensor.abstellkammer_tuer"]
assert record.assignment.review_required is False
assert "kein Helligkeitssensor erforderlich" in record.assignment.reason
def test_reconciliation_rejects_ambiguous_numeric_mapping(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [
@@ -352,6 +392,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

@@ -127,6 +127,22 @@ def _install_service(tmp_path: Path) -> None:
area_name="Abstellkammer",
state="off",
),
HaEntitySummary(
entity_id="fan.bad_luefter",
domain="fan",
friendly_name="Bad Lüfter",
area_name="Bad",
),
HaEntitySummary(
entity_id="sensor.bad_luftfeuchtigkeit",
domain="sensor",
device_class="humidity",
state_class="measurement",
unit_of_measurement="%",
friendly_name="Bad Luftfeuchtigkeit",
area_name="Bad",
state="68",
),
HaEntitySummary(
entity_id="sensor.pfsense_interface_vpn_inbytes",
domain="sensor",
@@ -516,7 +532,7 @@ def test_dashboard_overview_uses_cache_without_ha_roundtrip(tmp_path: Path) -> N
assert reader.read_entities_calls == calls_before
payload = response.json()
assert payload["cache"]["available"] is True
assert payload["cache"]["entity_count"] == 4
assert payload["cache"]["entity_count"] == 6
assert payload["actuators"][0]["friendly_name"] == "Abstellkammer Licht"
assert payload["discovery_groups"]
assert payload["jobs"]["jobs"][-1]["kind"] == "discovery"
@@ -618,11 +634,37 @@ def test_dashboard_system_and_start_do_not_materialize_entity_cache(
assert system_response.status_code == 200
assert system_response.json()["actuators"] == []
assert system_response.json()["cache"]["entity_count"] == 4
assert system_response.json()["cache"]["entity_count"] == 6
assert start_response.status_code == 200
assert start_response.json()["actuators"][0]["friendly_name"] == "Abstellkammer Licht"
def test_room_management_overview_groups_actuators_with_sensors_and_rules(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
response = client.get("/v1/actuators/settings/rooms")
assert response.status_code == 200
payload = response.json()
room = payload["rooms"][0]
assert room["room"] == "Abstellkammer"
assert room["actuator_count"] == 1
actuator = room["actuators"][0]
assert actuator["actuator_entity_id"] == "light.abstellkammer"
assert actuator["sensors"]
assert actuator["prediction_rules"]
assert room["suggested_actions"]
assert room["sensor_count"] >= 2
assert any(sensor["entity_id"] == "binary_sensor.abstellkammer_motion" for sensor in room["sensors"])
bad = next(item for item in payload["rooms"] if item["room"] == "Bad")
assert bad["actuator_count"] == 1
assert bad["actuators"][0]["lifecycle_status"] == "unconfigured"
assert any(action["category"] == "belueftung" for action in bad["suggested_actions"])
def test_actuator_detail_uses_compact_payload(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

@@ -22,6 +22,10 @@ def test_dashboard_is_served_at_root() -> None:
assert "Ohne deine spätere Freigabe wird nichts geschaltet" not in response.text
assert "Du wählst keine Sensoren und erstellst keine Regeln" not in response.text
assert "Freigabestatus" in response.text
assert "Sprache, Räume, Sensoren, Aktoren und Vorhersagen an einem Ort." in response.text
assert "room-management" in response.text
assert 'api("v1/actuators/settings/rooms")' in response.text
assert "Auswahl speichern" in response.text
assert "SillyHome übernehmen lassen" in response.text
assert "Passende Home-Assistant-Automationen" in response.text
assert "Pausieren" in response.text

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()