Expand room planning management
This commit is contained in:
11
CHANGELOG.md
11
CHANGELOG.md
@@ -1,5 +1,16 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
## 1.7.6 - 2026-07-26
|
||||||
- Einstellungen um eine Raumverwaltung erweitert: Räume zeigen Aktoren,
|
- Einstellungen um eine Raumverwaltung erweitert: Räume zeigen Aktoren,
|
||||||
aktive/optionale/nicht nötige Sensoren und lesbare Vorhersage-Regeln in
|
aktive/optionale/nicht nötige Sensoren und lesbare Vorhersage-Regeln in
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: SillyHome Next
|
name: SillyHome Next
|
||||||
version: "1.7.6"
|
version: "1.7.7"
|
||||||
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
|
||||||
|
|||||||
@@ -194,6 +194,19 @@ class RoomManagementSensor(BaseModel):
|
|||||||
reason: str
|
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):
|
class RoomManagementActuator(BaseModel):
|
||||||
actuator_entity_id: str
|
actuator_entity_id: str
|
||||||
friendly_name: str | None = None
|
friendly_name: str | None = None
|
||||||
@@ -206,15 +219,20 @@ class RoomManagementActuator(BaseModel):
|
|||||||
selected_context_entity_ids: list[str] = Field(default_factory=list)
|
selected_context_entity_ids: list[str] = Field(default_factory=list)
|
||||||
sensors: list[RoomManagementSensor] = Field(default_factory=list)
|
sensors: list[RoomManagementSensor] = Field(default_factory=list)
|
||||||
prediction_rules: list[str] = Field(default_factory=list)
|
prediction_rules: list[str] = Field(default_factory=list)
|
||||||
|
suggested_actions: list[RoomManagementAction] = Field(default_factory=list)
|
||||||
management_hint: str
|
management_hint: str
|
||||||
|
|
||||||
|
|
||||||
class RoomManagementGroup(BaseModel):
|
class RoomManagementGroup(BaseModel):
|
||||||
room: str
|
room: str
|
||||||
actuator_count: int
|
actuator_count: int
|
||||||
|
sensor_count: int = 0
|
||||||
|
action_count: int = 0
|
||||||
sensors: list[RoomManagementSensor] = Field(default_factory=list)
|
sensors: list[RoomManagementSensor] = Field(default_factory=list)
|
||||||
actuators: list[RoomManagementActuator] = Field(default_factory=list)
|
actuators: list[RoomManagementActuator] = Field(default_factory=list)
|
||||||
prediction_rules: list[str] = 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):
|
class RoomManagementOverview(BaseModel):
|
||||||
@@ -511,8 +529,9 @@ def room_management_overview(request: Request) -> RoomManagementOverview:
|
|||||||
if entity_id
|
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}
|
configured_ids = {record.actuator_entity_id for record in records}
|
||||||
rooms: dict[str, RoomManagementGroup] = {}
|
rooms = _build_room_shells(entities, discovered)
|
||||||
for record in records:
|
for record in records:
|
||||||
actuator = entities.get(record.actuator_entity_id)
|
actuator = entities.get(record.actuator_entity_id)
|
||||||
room = (
|
room = (
|
||||||
@@ -558,6 +577,12 @@ def room_management_overview(request: Request) -> RoomManagementOverview:
|
|||||||
selected_context_entity_ids=record.assignment.selected_context_entity_ids,
|
selected_context_entity_ids=record.assignment.selected_context_entity_ids,
|
||||||
sensors=sensors,
|
sensors=sensors,
|
||||||
prediction_rules=_prediction_rule_lines(record, ranked_candidates),
|
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),
|
management_hint=_management_hint(record, has_opening_context),
|
||||||
)
|
)
|
||||||
if room not in rooms:
|
if room not in rooms:
|
||||||
@@ -569,6 +594,52 @@ def room_management_overview(request: Request) -> RoomManagementOverview:
|
|||||||
*actuator_group.prediction_rules,
|
*actuator_group.prediction_rules,
|
||||||
])[:8]
|
])[:8]
|
||||||
rooms[room].sensors = _merge_room_sensors(rooms[room].sensors, sensors)
|
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
|
||||||
|
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 = [
|
unmanaged = [
|
||||||
suggestion for suggestion in suggest_actuators(request, service._ha_reader)
|
suggestion for suggestion in suggest_actuators(request, service._ha_reader)
|
||||||
if suggestion.entity_id not in configured_ids
|
if suggestion.entity_id not in configured_ids
|
||||||
@@ -1057,6 +1128,119 @@ def _validate_weight_payload(payload: WeightOverrideRequest) -> None:
|
|||||||
raise ValueError(f"Ungültige Entity-ID in Gruppe {group.name}: {entity_id}")
|
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 _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:
|
def _candidate_room(record: ActuatorRecord) -> str | None:
|
||||||
for candidate in [*record.context_candidates, *record.numeric_candidates]:
|
for candidate in [*record.context_candidates, *record.numeric_candidates]:
|
||||||
if candidate.area_name:
|
if candidate.area_name:
|
||||||
@@ -1147,8 +1331,14 @@ def _sensor_category_label(candidate: AssignmentCandidate) -> str:
|
|||||||
return "Helligkeit"
|
return "Helligkeit"
|
||||||
if device_class in {"humidity", "moisture"}:
|
if device_class in {"humidity", "moisture"}:
|
||||||
return "Luftfeuchtigkeit"
|
return "Luftfeuchtigkeit"
|
||||||
|
if device_class == "temperature":
|
||||||
|
return "Temperatur"
|
||||||
if device_class in {"power", "energy", "current", "voltage"}:
|
if device_class in {"power", "energy", "current", "voltage"}:
|
||||||
return "Energie"
|
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"}:
|
if candidate.domain in {"cover"}:
|
||||||
return "Rollo/Cover"
|
return "Rollo/Cover"
|
||||||
if candidate.domain in {"zone", "person", "device_tracker"}:
|
if candidate.domain in {"zone", "person", "device_tracker"}:
|
||||||
@@ -1220,6 +1410,116 @@ def _prediction_rule_lines(
|
|||||||
return _unique_lines(result)[:8] or ["Noch keine stabile Vorhersage; erst Kontext prüfen und weiter beobachten."]
|
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]:
|
def _pattern_rule_lines(patterns: list[BehaviorPattern]) -> list[str]:
|
||||||
buckets: dict[tuple[str, tuple[tuple[str, str], ...]], int] = {}
|
buckets: dict[tuple[str, tuple[tuple[str, str], ...]], int] = {}
|
||||||
attrs: dict[tuple[str, tuple[tuple[str, str], ...]], dict[str, object]] = {}
|
attrs: dict[tuple[str, tuple[tuple[str, str], ...]], dict[str, object]] = {}
|
||||||
|
|||||||
@@ -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.4",
|
version="1.7.7",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
app.state.settings = load_settings()
|
app.state.settings = load_settings()
|
||||||
@@ -167,6 +167,8 @@ async def _periodic_reconciliation(app: FastAPI) -> None:
|
|||||||
engine = getattr(app.state, "behavior_engine", None)
|
engine = getattr(app.state, "behavior_engine", None)
|
||||||
if isinstance(engine, BehaviorEngine):
|
if isinstance(engine, BehaviorEngine):
|
||||||
await asyncio.to_thread(engine.train_all)
|
await asyncio.to_thread(engine.train_all)
|
||||||
|
await asyncio.to_thread(engine.evaluate_all)
|
||||||
|
await asyncio.to_thread(engine.refresh_planning_insights)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Geplante Reconciliation fehlgeschlagen; nächster Lauf versucht es erneut.")
|
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(service.reconcile_all, "startup")
|
||||||
await asyncio.to_thread(engine.train_all)
|
await asyncio.to_thread(engine.train_all)
|
||||||
await asyncio.to_thread(engine.evaluate_all)
|
await asyncio.to_thread(engine.evaluate_all)
|
||||||
|
await asyncio.to_thread(engine.refresh_planning_insights)
|
||||||
logger.info("Startup-Reconciliation erfolgreich abgeschlossen.")
|
logger.info("Startup-Reconciliation erfolgreich abgeschlossen.")
|
||||||
return
|
return
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -1410,14 +1410,24 @@ function renderRoomManagement() {
|
|||||||
const rooms = (overview.rooms || []).filter(room =>
|
const rooms = (overview.rooms || []).filter(room =>
|
||||||
!query
|
!query
|
||||||
|| normalizedSearch(room.room).includes(query)
|
|| normalizedSearch(room.room).includes(query)
|
||||||
|| (room.actuators || []).some(actuator =>
|
|| (room.actuators || []).some(actuator =>
|
||||||
matchesSearch({
|
matchesSearch({
|
||||||
entity_id: actuator.actuator_entity_id,
|
entity_id: actuator.actuator_entity_id,
|
||||||
friendly_name: actuator.friendly_name,
|
friendly_name: actuator.friendly_name,
|
||||||
area_name: room.room,
|
area_name: room.room,
|
||||||
}, query)
|
}, query)
|
||||||
)
|
)
|
||||||
);
|
|| (room.sensors || []).some(sensor =>
|
||||||
|
matchesSearch({
|
||||||
|
entity_id: sensor.entity_id,
|
||||||
|
friendly_name: sensor.friendly_name,
|
||||||
|
area_name: room.room,
|
||||||
|
}, query)
|
||||||
|
)
|
||||||
|
|| (room.suggested_actions || []).some(action =>
|
||||||
|
normalizedSearch(`${action.category} ${action.title} ${action.when} ${action.then}`).includes(query)
|
||||||
|
)
|
||||||
|
);
|
||||||
const unmanaged = overview.unmanaged_actuators || [];
|
const unmanaged = overview.unmanaged_actuators || [];
|
||||||
box.innerHTML = `
|
box.innerHTML = `
|
||||||
${rooms.length ? rooms.map(room => `
|
${rooms.length ? rooms.map(room => `
|
||||||
@@ -1425,9 +1435,9 @@ function renderRoomManagement() {
|
|||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<h3>${escapeHtml(room.room)}</h3>
|
<h3>${escapeHtml(room.room)}</h3>
|
||||||
<p class="muted">${escapeHtml(room.actuator_count)} ${escapeHtml(uiLang === "en" ? "actuator(s)" : "Aktor(en)")}</p>
|
<p class="muted">${escapeHtml(room.actuator_count)} ${escapeHtml(uiLang === "en" ? "actuator(s)" : "Aktor(en)")} · ${escapeHtml(room.sensor_count || 0)} ${escapeHtml(uiLang === "en" ? "sensor(s)" : "Sensor(en)")}</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="chip">${escapeHtml((room.sensors || []).filter(sensor => sensor.active).length)} ${escapeHtml(uiLang === "en" ? "active sensors" : "aktive Sensoren")}</span>
|
<span class="chip">${escapeHtml(room.action_count || 0)} ${escapeHtml(uiLang === "en" ? "actions" : "Handlungen")}</span>
|
||||||
</header>
|
</header>
|
||||||
<div class="sensor-strip">
|
<div class="sensor-strip">
|
||||||
${(room.sensors || []).slice(0, 12).map(sensor => sensorPill(sensor)).join("") || `<span class="muted">${escapeHtml(uiLang === "en" ? "No sensors selected yet." : "Noch keine Sensoren ausgewählt.")}</span>`}
|
${(room.sensors || []).slice(0, 12).map(sensor => sensorPill(sensor)).join("") || `<span class="muted">${escapeHtml(uiLang === "en" ? "No sensors selected yet." : "Noch keine Sensoren ausgewählt.")}</span>`}
|
||||||
@@ -1435,6 +1445,7 @@ function renderRoomManagement() {
|
|||||||
<div class="decision-list">
|
<div class="decision-list">
|
||||||
${(room.prediction_rules || []).slice(0, 5).map(rule => `<div class="decision-row">${escapeHtml(rule)}</div>`).join("")}
|
${(room.prediction_rules || []).slice(0, 5).map(rule => `<div class="decision-row">${escapeHtml(rule)}</div>`).join("")}
|
||||||
</div>
|
</div>
|
||||||
|
${roomActions(room.suggested_actions || [])}
|
||||||
<div class="card-list">
|
<div class="card-list">
|
||||||
${(room.actuators || []).map(actuator => roomActuatorCard(actuator)).join("")}
|
${(room.actuators || []).map(actuator => roomActuatorCard(actuator)).join("")}
|
||||||
</div>
|
</div>
|
||||||
@@ -1515,6 +1526,7 @@ function roomActuatorCard(actuator) {
|
|||||||
<div class="decision-list">
|
<div class="decision-list">
|
||||||
${(actuator.prediction_rules || []).slice(0, 4).map(rule => `<div class="decision-row">${escapeHtml(rule)}</div>`).join("")}
|
${(actuator.prediction_rules || []).slice(0, 4).map(rule => `<div class="decision-row">${escapeHtml(rule)}</div>`).join("")}
|
||||||
</div>
|
</div>
|
||||||
|
${roomActions(actuator.suggested_actions || [])}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="secondary" onclick="showActuator('${escapeHtml(actuator.actuator_entity_id)}')">${escapeHtml(uiLang === "en" ? "Open details" : "Details öffnen")}</button>
|
<button class="secondary" onclick="showActuator('${escapeHtml(actuator.actuator_entity_id)}')">${escapeHtml(uiLang === "en" ? "Open details" : "Details öffnen")}</button>
|
||||||
<button onclick="saveRoomAssignment('${escapeHtml(actuator.actuator_entity_id)}', '${escapeHtml(actuator.selected_numeric_entity_id || "")}', '${escapeHtml(selectedContextIds.join(","))}')">${escapeHtml(uiLang === "en" ? "Save selection" : "Auswahl speichern")}</button>
|
<button onclick="saveRoomAssignment('${escapeHtml(actuator.actuator_entity_id)}', '${escapeHtml(actuator.selected_numeric_entity_id || "")}', '${escapeHtml(selectedContextIds.join(","))}')">${escapeHtml(uiLang === "en" ? "Save selection" : "Auswahl speichern")}</button>
|
||||||
@@ -1524,6 +1536,21 @@ function roomActuatorCard(actuator) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function roomActions(actions) {
|
||||||
|
if (!actions.length) return "";
|
||||||
|
return `
|
||||||
|
<div class="decision-list">
|
||||||
|
${actions.slice(0, 6).map(action => `
|
||||||
|
<div class="decision-row" title="${escapeHtml(action.why)}">
|
||||||
|
<strong>${escapeHtml(action.title)}</strong>
|
||||||
|
<span class="muted"> · ${escapeHtml(action.category)} · ${Math.round((action.confidence || 0) * 100)} %</span><br>
|
||||||
|
${escapeHtml(action.when)} -> ${escapeHtml(action.then)}
|
||||||
|
</div>
|
||||||
|
`).join("")}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
async function saveRoomAssignment(actuatorId, numericEntityId, contextCsv) {
|
async function saveRoomAssignment(actuatorId, numericEntityId, contextCsv) {
|
||||||
const contextEntityIds = contextCsv.split(",").map(item => item.trim()).filter(Boolean);
|
const contextEntityIds = contextCsv.split(",").map(item => item.trim()).filter(Boolean);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "sillyhome-next"
|
name = "sillyhome-next"
|
||||||
version = "1.7.6"
|
version = "1.7.7"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -127,6 +127,22 @@ def _install_service(tmp_path: Path) -> None:
|
|||||||
area_name="Abstellkammer",
|
area_name="Abstellkammer",
|
||||||
state="off",
|
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(
|
HaEntitySummary(
|
||||||
entity_id="sensor.pfsense_interface_vpn_inbytes",
|
entity_id="sensor.pfsense_interface_vpn_inbytes",
|
||||||
domain="sensor",
|
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
|
assert reader.read_entities_calls == calls_before
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
assert payload["cache"]["available"] is True
|
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["actuators"][0]["friendly_name"] == "Abstellkammer Licht"
|
||||||
assert payload["discovery_groups"]
|
assert payload["discovery_groups"]
|
||||||
assert payload["jobs"]["jobs"][-1]["kind"] == "discovery"
|
assert payload["jobs"]["jobs"][-1]["kind"] == "discovery"
|
||||||
@@ -618,7 +634,7 @@ def test_dashboard_system_and_start_do_not_materialize_entity_cache(
|
|||||||
|
|
||||||
assert system_response.status_code == 200
|
assert system_response.status_code == 200
|
||||||
assert system_response.json()["actuators"] == []
|
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.status_code == 200
|
||||||
assert start_response.json()["actuators"][0]["friendly_name"] == "Abstellkammer Licht"
|
assert start_response.json()["actuators"][0]["friendly_name"] == "Abstellkammer Licht"
|
||||||
|
|
||||||
@@ -639,8 +655,15 @@ def test_room_management_overview_groups_actuators_with_sensors_and_rules(tmp_pa
|
|||||||
assert actuator["actuator_entity_id"] == "light.abstellkammer"
|
assert actuator["actuator_entity_id"] == "light.abstellkammer"
|
||||||
assert actuator["sensors"]
|
assert actuator["sensors"]
|
||||||
assert actuator["prediction_rules"]
|
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"])
|
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:
|
def test_actuator_detail_uses_compact_payload(tmp_path: Path) -> None:
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
|
|||||||
Reference in New Issue
Block a user