Compare commits

...

2 Commits
v1.7.6 ... 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
7 changed files with 425 additions and 16 deletions

View File

@@ -1,5 +1,24 @@
# Changelog # 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 ## 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

View File

@@ -1,5 +1,5 @@
name: SillyHome Next name: SillyHome Next
version: "1.7.6" version: "1.7.8"
slug: sillyhome_next slug: sillyhome_next
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
url: http://192.168.6.31:3000/pino/sillyhome-next url: http://192.168.6.31:3000/pino/sillyhome-next

View File

@@ -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,54 @@ 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
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 = [ 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 +1130,154 @@ 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 _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: 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 +1368,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 +1447,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]] = {}

View File

@@ -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.8",
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:

View File

@@ -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 {

View File

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

View File

@@ -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: