Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98a2b2cc38 | |||
| 387e027fe2 | |||
| f8bee92e64 | |||
| 9ddb065f62 | |||
| 8222f24ebe | |||
| a7a2f8c78a |
39
CHANGELOG.md
39
CHANGELOG.md
@@ -1,5 +1,44 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.7.20 - 2026-06-17
|
||||||
|
- Dashboard-Übersicht ist kompatibel mit dem leichten Summary-Format und greift
|
||||||
|
nicht mehr auf `record.behavior.status` aus dem Vollformat zu.
|
||||||
|
|
||||||
|
## 0.7.19 - 2026-06-17
|
||||||
|
- Dashboard-Übersicht nutzt einen leichten `/v1/actuators/summary`-Endpunkt
|
||||||
|
statt voller Lernmuster und kompletter HA-Entityliste.
|
||||||
|
- Nach Aktionen werden Dashboard-Caches gezielt invalidiert, damit keine
|
||||||
|
stale oder doppelt geladenen Einträge entstehen.
|
||||||
|
|
||||||
|
## 0.7.18 - 2026-06-16
|
||||||
|
- Dashboard lädt Aktoren, Entities und Discovery nur noch einmal pro Refresh und
|
||||||
|
rendert daraus Auswahl und Übersicht ohne doppelte API-Ladewege.
|
||||||
|
- Manuelle Kontext-Evidenz wird dedupliziert, damit Hinweise wie
|
||||||
|
"Manuell vom Nutzer als relevant festgelegt" nicht mehrfach erscheinen.
|
||||||
|
- Kontextauswahl ist vollständiger: Feuchte, Wetter, Licht-/Schalterzustände,
|
||||||
|
Bewegungs-/Tür-/Präsenzmelder, PV/Akku/Einspeisung und Helper werden sauberer
|
||||||
|
kategorisiert und per Suche/Kategorie erreichbar.
|
||||||
|
- Domainspezifische Zuordnung geschärft: Lüftungen bevorzugen Feuchte/Temperatur,
|
||||||
|
Lichter Helligkeit/Bewegung/Tür/Präsenz, Heizungen Temperatur/Anwesenheit/Wetter.
|
||||||
|
|
||||||
|
## 0.7.17 - 2026-06-16
|
||||||
|
- WebSocket-Eventpfad ist schneller: irrelevante HA-State-Changes werden vor
|
||||||
|
dem teuren State-Cache-Listenbau verworfen.
|
||||||
|
- WebSocket nutzt Keepalive und reconnectet nach Abbrüchen nach 1s statt 5s.
|
||||||
|
|
||||||
|
## 0.7.16 - 2026-06-16
|
||||||
|
- Beobachtete Aktoren werden in der Übersicht nach Raum oder Typ gruppiert und
|
||||||
|
mit Friendly Name angezeigt.
|
||||||
|
|
||||||
|
## 0.7.15 - 2026-06-16
|
||||||
|
- Add-on-Start ist robust gegen Home-Assistant-Core-502 beim Systemboot:
|
||||||
|
API und WebSocket-Listener starten trotzdem, Reconciliation/Training werden
|
||||||
|
im Hintergrund mit Retry nachgeholt.
|
||||||
|
- Periodische Reconciliation und Fallback-Auswertung beenden den Dienst nicht
|
||||||
|
mehr bei temporären HA-Fehlern.
|
||||||
|
- Add-on-Watchdog prüft `/health`, damit Supervisor den Dienst nach Absturz
|
||||||
|
wieder starten kann.
|
||||||
|
|
||||||
## 0.7.14 - 2026-06-16
|
## 0.7.14 - 2026-06-16
|
||||||
- Onboarding-Vorschläge laden im Dashboard nachgelagert, damit Status,
|
- Onboarding-Vorschläge laden im Dashboard nachgelagert, damit Status,
|
||||||
Aktor-Auswahl und bestehende Geräte nicht auf Automation-Discovery warten.
|
Aktor-Auswahl und bestehende Geräte nicht auf Automation-Discovery warten.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: SillyHome Next
|
name: SillyHome Next
|
||||||
version: "0.7.14"
|
version: "0.7.20"
|
||||||
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
|
||||||
@@ -7,6 +7,7 @@ arch:
|
|||||||
- amd64
|
- amd64
|
||||||
startup: application
|
startup: application
|
||||||
boot: auto
|
boot: auto
|
||||||
|
watchdog: http://[HOST]:[PORT:8000]/health
|
||||||
init: false
|
init: false
|
||||||
ingress: true
|
ingress: true
|
||||||
ingress_port: 8000
|
ingress_port: 8000
|
||||||
|
|||||||
@@ -72,29 +72,42 @@ _MANUAL_CONTEXT_DOMAINS = frozenset({
|
|||||||
"device_tracker",
|
"device_tracker",
|
||||||
"fan",
|
"fan",
|
||||||
"humidifier",
|
"humidifier",
|
||||||
|
"input_boolean",
|
||||||
|
"input_number",
|
||||||
|
"input_select",
|
||||||
"light",
|
"light",
|
||||||
|
"media_player",
|
||||||
"person",
|
"person",
|
||||||
|
"remote",
|
||||||
|
"scene",
|
||||||
"sensor",
|
"sensor",
|
||||||
|
"sun",
|
||||||
"switch",
|
"switch",
|
||||||
"weather",
|
"weather",
|
||||||
})
|
})
|
||||||
_CONTEXT_SUGGESTION_LIMIT = 120
|
_CONTEXT_SUGGESTION_LIMIT = 500
|
||||||
_OUTDOOR_TOKENS = frozenset({"aussen", "außen", "outdoor", "garten", "terrasse", "balkon"})
|
_OUTDOOR_TOKENS = frozenset({"aussen", "außen", "outdoor", "garten", "terrasse", "balkon"})
|
||||||
_DIAGNOSTIC_TOKENS = frozenset({
|
_DIAGNOSTIC_TOKENS = frozenset({
|
||||||
"basic",
|
"basic",
|
||||||
"battery",
|
"battery",
|
||||||
|
"bytes",
|
||||||
"connect",
|
"connect",
|
||||||
"count",
|
"count",
|
||||||
|
"data",
|
||||||
"diagnostic",
|
"diagnostic",
|
||||||
"firmware",
|
"firmware",
|
||||||
"gesehen",
|
"gesehen",
|
||||||
"heat",
|
"heat",
|
||||||
|
"inbytes",
|
||||||
|
"interface",
|
||||||
"last",
|
"last",
|
||||||
"linkquality",
|
"linkquality",
|
||||||
"knoten",
|
"knoten",
|
||||||
"knotens",
|
"knotens",
|
||||||
"mqtt",
|
"mqtt",
|
||||||
"node",
|
"node",
|
||||||
|
"outbytes",
|
||||||
|
"pfsense",
|
||||||
"reason",
|
"reason",
|
||||||
"restart",
|
"restart",
|
||||||
"rssi",
|
"rssi",
|
||||||
@@ -105,6 +118,7 @@ _DIAGNOSTIC_TOKENS = frozenset({
|
|||||||
"overheating",
|
"overheating",
|
||||||
"overload",
|
"overload",
|
||||||
"uptime",
|
"uptime",
|
||||||
|
"vpn",
|
||||||
"uberhitzung",
|
"uberhitzung",
|
||||||
"ueberhitzung",
|
"ueberhitzung",
|
||||||
"ueberlast",
|
"ueberlast",
|
||||||
@@ -175,13 +189,10 @@ class ActuatorReconciliationService:
|
|||||||
selected = entity.entity_id in selected_ids
|
selected = entity.entity_id in selected_ids
|
||||||
if selected:
|
if selected:
|
||||||
score = max(score, 1.0)
|
score = max(score, 1.0)
|
||||||
if not selected and (
|
if not selected and _is_diagnostic_context(entity):
|
||||||
_is_diagnostic_context(entity)
|
|
||||||
or not _has_context_relationship(actuator, entity)
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
if not selected and score < 0.1:
|
|
||||||
continue
|
continue
|
||||||
|
if not selected and not _has_context_relationship(actuator, entity):
|
||||||
|
score = max(score, 0.01)
|
||||||
ranked.append((score, _context_sort_group(entity), entity))
|
ranked.append((score, _context_sort_group(entity), entity))
|
||||||
ranked.sort(
|
ranked.sort(
|
||||||
key=lambda item: (
|
key=lambda item: (
|
||||||
@@ -849,12 +860,17 @@ def _merge_manual_candidates(
|
|||||||
for entity_id in selected_entity_ids:
|
for entity_id in selected_entity_ids:
|
||||||
existing = by_id.get(entity_id)
|
existing = by_id.get(entity_id)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
|
evidence = [
|
||||||
|
item
|
||||||
|
for item in existing.evidence
|
||||||
|
if item != "Manuell vom Nutzer als relevant festgelegt."
|
||||||
|
]
|
||||||
by_id[entity_id] = existing.model_copy(
|
by_id[entity_id] = existing.model_copy(
|
||||||
update={
|
update={
|
||||||
"auto_accepted": True,
|
"auto_accepted": True,
|
||||||
"confidence": 1.0,
|
"confidence": 1.0,
|
||||||
"evidence": [
|
"evidence": [
|
||||||
*existing.evidence,
|
*evidence,
|
||||||
"Manuell vom Nutzer als relevant festgelegt.",
|
"Manuell vom Nutzer als relevant festgelegt.",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -883,13 +899,28 @@ def _merge_manual_candidates(
|
|||||||
|
|
||||||
def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]:
|
def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]:
|
||||||
if context:
|
if context:
|
||||||
return frozenset({"door", "garage_door", "motion", "occupancy", "opening", "presence"})
|
mapping = {
|
||||||
|
"climate": {"occupancy", "presence", "window"},
|
||||||
|
"cover": {"illuminance", "wind_speed"},
|
||||||
|
"fan": {"humidity", "moisture", "occupancy", "presence", "temperature"},
|
||||||
|
"humidifier": {"humidity", "moisture", "temperature"},
|
||||||
|
"light": {"door", "garage_door", "motion", "occupancy", "opening", "presence", "window"},
|
||||||
|
"switch": {"door", "garage_door", "motion", "occupancy", "opening", "presence", "window"},
|
||||||
|
}
|
||||||
|
return frozenset(
|
||||||
|
mapping.get(
|
||||||
|
domain,
|
||||||
|
{"door", "garage_door", "motion", "occupancy", "opening", "presence"},
|
||||||
|
)
|
||||||
|
)
|
||||||
mapping = {
|
mapping = {
|
||||||
"climate": {"temperature", "humidity", "power"},
|
"climate": {"temperature", "humidity"},
|
||||||
"cover": {"illuminance", "temperature", "wind_speed"},
|
"cover": {"illuminance", "temperature", "wind_speed"},
|
||||||
"fan": {"temperature", "humidity", "power"},
|
"fan": {"temperature", "humidity", "moisture"},
|
||||||
"humidifier": {"humidity", "temperature", "power"},
|
"humidifier": {"humidity", "moisture", "temperature"},
|
||||||
"light": {"illuminance", "power", "energy"},
|
"light": {"illuminance"},
|
||||||
|
"media_player": {"power", "energy"},
|
||||||
|
"remote": {"battery"},
|
||||||
"switch": {"power", "energy", "current"},
|
"switch": {"power", "energy", "current"},
|
||||||
"valve": {"temperature", "pressure", "humidity"},
|
"valve": {"temperature", "pressure", "humidity"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,21 @@ class ActuatorSuggestion(BaseModel):
|
|||||||
likely_context_count: int = 0
|
likely_context_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ActuatorSummary(BaseModel):
|
||||||
|
actuator_entity_id: str
|
||||||
|
domain: str
|
||||||
|
enabled: bool
|
||||||
|
behavior_mode: str
|
||||||
|
behavior_status: str
|
||||||
|
lifecycle_status: str
|
||||||
|
activation_ready: bool
|
||||||
|
activation_reason: str
|
||||||
|
sample_count: int
|
||||||
|
prediction_target_state: str | None = None
|
||||||
|
prediction_confidence: float | None = None
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
@router.get("/discovery", response_model=list[HaEntitySummary])
|
@router.get("/discovery", response_model=list[HaEntitySummary])
|
||||||
def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaEntitySummary]:
|
def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaEntitySummary]:
|
||||||
entities = {entity.entity_id: entity for entity in ha_reader.read_entities()}
|
entities = {entity.entity_id: entity for entity in ha_reader.read_entities()}
|
||||||
@@ -143,6 +158,35 @@ def context_options(
|
|||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary", response_model=list[ActuatorSummary])
|
||||||
|
def list_configured_summary(request: Request) -> list[ActuatorSummary]:
|
||||||
|
return [
|
||||||
|
ActuatorSummary(
|
||||||
|
actuator_entity_id=record.actuator_entity_id,
|
||||||
|
domain=record.actuator_entity_id.split(".", 1)[0],
|
||||||
|
enabled=record.enabled,
|
||||||
|
behavior_mode=record.behavior.mode.value,
|
||||||
|
behavior_status=record.behavior.status.value,
|
||||||
|
lifecycle_status=record.lifecycle.status.value,
|
||||||
|
activation_ready=record.behavior.activation_ready,
|
||||||
|
activation_reason=record.behavior.activation_reason,
|
||||||
|
sample_count=record.behavior.sample_count,
|
||||||
|
prediction_target_state=(
|
||||||
|
record.behavior.prediction.target_state
|
||||||
|
if record.behavior.prediction is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
prediction_confidence=(
|
||||||
|
record.behavior.prediction.confidence
|
||||||
|
if record.behavior.prediction is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
updated_at=record.updated_at.isoformat(),
|
||||||
|
)
|
||||||
|
for record in _service(request).list_configured()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ActuatorRecord])
|
@router.get("", response_model=list[ActuatorRecord])
|
||||||
def list_configured(request: Request) -> list[ActuatorRecord]:
|
def list_configured(request: Request) -> list[ActuatorRecord]:
|
||||||
return _service(request).list_configured()
|
return _service(request).list_configured()
|
||||||
|
|||||||
@@ -900,6 +900,8 @@ def predict_behavior(
|
|||||||
def service_for_state(domain: str, target_state: str) -> str | None:
|
def service_for_state(domain: str, target_state: str) -> str | None:
|
||||||
if domain in {"fan", "humidifier", "light", "media_player", "remote", "switch"}:
|
if domain in {"fan", "humidifier", "light", "media_player", "remote", "switch"}:
|
||||||
return {"on": "turn_on", "off": "turn_off"}.get(target_state)
|
return {"on": "turn_on", "off": "turn_off"}.get(target_state)
|
||||||
|
if domain == "scene":
|
||||||
|
return "turn_on" if target_state == "on" else None
|
||||||
if domain == "cover":
|
if domain == "cover":
|
||||||
return {"open": "open_cover", "closed": "close_cover"}.get(target_state)
|
return {"open": "open_cover", "closed": "close_cover"}.get(target_state)
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ _ACTUATOR_DOMAINS = frozenset({
|
|||||||
"media_player",
|
"media_player",
|
||||||
"number",
|
"number",
|
||||||
"remote",
|
"remote",
|
||||||
|
"scene",
|
||||||
"siren",
|
"siren",
|
||||||
"switch",
|
"switch",
|
||||||
"valve",
|
"valve",
|
||||||
@@ -229,6 +230,8 @@ def _actuator_category(entity: HaEntitySummary) -> str:
|
|||||||
return "fan"
|
return "fan"
|
||||||
if entity.domain in {"media_player", "remote"}:
|
if entity.domain in {"media_player", "remote"}:
|
||||||
return "media_tv"
|
return "media_tv"
|
||||||
|
if entity.domain == "scene":
|
||||||
|
return "scene"
|
||||||
if entity.domain in {"input_boolean", "number"}:
|
if entity.domain in {"input_boolean", "number"}:
|
||||||
return "helper"
|
return "helper"
|
||||||
return entity.domain
|
return entity.domain
|
||||||
|
|||||||
80
app/main.py
80
app/main.py
@@ -43,6 +43,7 @@ class _WsStatus:
|
|||||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
settings = app.state.settings
|
settings = app.state.settings
|
||||||
client: HaClient | None = None
|
client: HaClient | None = None
|
||||||
|
startup_task: asyncio.Task[None] | None = None
|
||||||
reconcile_task: asyncio.Task[None] | None = None
|
reconcile_task: asyncio.Task[None] | None = None
|
||||||
event_listener_task: asyncio.Task[None] | None = None
|
event_listener_task: asyncio.Task[None] | None = None
|
||||||
fallback_task: asyncio.Task[None] | None = None
|
fallback_task: asyncio.Task[None] | None = None
|
||||||
@@ -74,15 +75,17 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||||||
settings=settings,
|
settings=settings,
|
||||||
)
|
)
|
||||||
app.state.ws_status = _WsStatus()
|
app.state.ws_status = _WsStatus()
|
||||||
await asyncio.to_thread(app.state.actuator_service.reconcile_all, "startup")
|
startup_task = asyncio.create_task(_startup_reconciliation(app))
|
||||||
await asyncio.to_thread(app.state.behavior_engine.train_all)
|
|
||||||
await asyncio.to_thread(app.state.behavior_engine.evaluate_all)
|
|
||||||
reconcile_task = asyncio.create_task(_periodic_reconciliation(app))
|
reconcile_task = asyncio.create_task(_periodic_reconciliation(app))
|
||||||
event_listener_task = asyncio.create_task(_ha_event_listener(app, client))
|
event_listener_task = asyncio.create_task(_ha_event_listener(app, client))
|
||||||
fallback_task = asyncio.create_task(_fallback_prediction(app))
|
fallback_task = asyncio.create_task(_fallback_prediction(app))
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
if startup_task is not None:
|
||||||
|
startup_task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await startup_task
|
||||||
if reconcile_task is not None:
|
if reconcile_task is not None:
|
||||||
reconcile_task.cancel()
|
reconcile_task.cancel()
|
||||||
with suppress(asyncio.CancelledError):
|
with suppress(asyncio.CancelledError):
|
||||||
@@ -102,7 +105,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="0.7.14",
|
version="0.7.20",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
app.state.settings = load_settings()
|
app.state.settings = load_settings()
|
||||||
@@ -147,10 +150,39 @@ async def _periodic_reconciliation(app: FastAPI) -> None:
|
|||||||
service = getattr(app.state, "actuator_service", None)
|
service = getattr(app.state, "actuator_service", None)
|
||||||
if not isinstance(service, ActuatorReconciliationService):
|
if not isinstance(service, ActuatorReconciliationService):
|
||||||
continue
|
continue
|
||||||
await asyncio.to_thread(service.reconcile_all, "scheduled")
|
try:
|
||||||
|
await asyncio.to_thread(service.reconcile_all, "scheduled")
|
||||||
|
engine = getattr(app.state, "behavior_engine", None)
|
||||||
|
if isinstance(engine, BehaviorEngine):
|
||||||
|
await asyncio.to_thread(engine.train_all)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Geplante Reconciliation fehlgeschlagen; nächster Lauf versucht es erneut.")
|
||||||
|
|
||||||
|
|
||||||
|
async def _startup_reconciliation(app: FastAPI) -> None:
|
||||||
|
delay_seconds = 5
|
||||||
|
while True:
|
||||||
|
service = getattr(app.state, "actuator_service", None)
|
||||||
engine = getattr(app.state, "behavior_engine", None)
|
engine = getattr(app.state, "behavior_engine", None)
|
||||||
if isinstance(engine, BehaviorEngine):
|
if not isinstance(service, ActuatorReconciliationService) or not isinstance(
|
||||||
|
engine,
|
||||||
|
BehaviorEngine,
|
||||||
|
):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
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)
|
||||||
|
logger.info("Startup-Reconciliation erfolgreich abgeschlossen.")
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Startup-Reconciliation verschoben: %s. Neuer Versuch in %ss.",
|
||||||
|
exc,
|
||||||
|
delay_seconds,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay_seconds)
|
||||||
|
delay_seconds = min(delay_seconds * 2, 60)
|
||||||
|
|
||||||
|
|
||||||
async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
||||||
@@ -179,7 +211,11 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
|||||||
if ws_status is not None:
|
if ws_status is not None:
|
||||||
ws_status.status = "connecting"
|
ws_status.status = "connecting"
|
||||||
try:
|
try:
|
||||||
async with websockets.connect(ws_url, ping_interval=None) as websocket:
|
async with websockets.connect(
|
||||||
|
ws_url,
|
||||||
|
ping_interval=20,
|
||||||
|
ping_timeout=10,
|
||||||
|
) as websocket:
|
||||||
auth_required_msg = await websocket.recv()
|
auth_required_msg = await websocket.recv()
|
||||||
auth_required_data = json.loads(auth_required_msg)
|
auth_required_data = json.loads(auth_required_msg)
|
||||||
if auth_required_data.get("type") != "auth_required":
|
if auth_required_data.get("type") != "auth_required":
|
||||||
@@ -231,6 +267,8 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
|||||||
continue
|
continue
|
||||||
new_state = event_data.get("new_state")
|
new_state = event_data.get("new_state")
|
||||||
_update_ha_state_cache(state_cache, entity_id, new_state)
|
_update_ha_state_cache(state_cache, entity_id, new_state)
|
||||||
|
if not _is_relevant_state_change(store, str(entity_id)):
|
||||||
|
continue
|
||||||
# Prüfe, ob Entity ein Aktor oder relevanter Kontext ist
|
# Prüfe, ob Entity ein Aktor oder relevanter Kontext ist
|
||||||
# Sofortige Vorhersage für betroffene Aktoren auslösen
|
# Sofortige Vorhersage für betroffene Aktoren auslösen
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
@@ -243,18 +281,22 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
|||||||
logger.warning("Ungültige JSON-Nachricht von HA-WebSocket")
|
logger.warning("Ungültige JSON-Nachricht von HA-WebSocket")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Fehler bei Event-Verarbeitung: %s", exc)
|
logger.exception("Fehler bei Event-Verarbeitung: %s", exc)
|
||||||
except (websockets.exceptions.ConnectionClosed, OSError) as exc:
|
except (
|
||||||
logger.warning("WebSocket-Verbindung unterbrochen: %s. Wiederholung in 5s...", exc)
|
websockets.exceptions.ConnectionClosed,
|
||||||
|
websockets.exceptions.InvalidStatus,
|
||||||
|
OSError,
|
||||||
|
) as exc:
|
||||||
|
logger.warning("WebSocket-Verbindung unterbrochen: %s. Wiederholung in 1s...", exc)
|
||||||
if ws_status is not None:
|
if ws_status is not None:
|
||||||
ws_status.status = "reconnecting"
|
ws_status.status = "reconnecting"
|
||||||
ws_status.error = str(exc)
|
ws_status.error = str(exc)
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(1)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Unerwarteter Fehler im Event-Listener: %s", exc)
|
logger.exception("Unerwarteter Fehler im Event-Listener: %s", exc)
|
||||||
if ws_status is not None:
|
if ws_status is not None:
|
||||||
ws_status.status = "error"
|
ws_status.status = "error"
|
||||||
ws_status.error = str(exc)
|
ws_status.error = str(exc)
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
# Fallback: periodische Vorhersage falls Event-Stream ausfällt
|
# Fallback: periodische Vorhersage falls Event-Stream ausfällt
|
||||||
@@ -280,7 +322,10 @@ async def _fallback_prediction(app: FastAPI) -> None:
|
|||||||
"Fallback-Vorhersage aktiv (WebSocket-Status: %s)",
|
"Fallback-Vorhersage aktiv (WebSocket-Status: %s)",
|
||||||
ws_status.status if ws_status else "unavailable",
|
ws_status.status if ws_status else "unavailable",
|
||||||
)
|
)
|
||||||
await asyncio.to_thread(engine.evaluate_all)
|
try:
|
||||||
|
await asyncio.to_thread(engine.evaluate_all)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Fallback-Vorhersage fehlgeschlagen.")
|
||||||
|
|
||||||
|
|
||||||
def _load_ha_state_cache(reader: HaReader) -> dict[str, HaEntitySummary]:
|
def _load_ha_state_cache(reader: HaReader) -> dict[str, HaEntitySummary]:
|
||||||
@@ -302,6 +347,17 @@ def _update_ha_state_cache(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_relevant_state_change(store: ActuatorStore, entity_id: str) -> bool:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
def _ha_entity_from_event(
|
def _ha_entity_from_event(
|
||||||
entity_id: str,
|
entity_id: str,
|
||||||
new_state: dict[str, object],
|
new_state: dict[str, object],
|
||||||
|
|||||||
@@ -144,6 +144,7 @@
|
|||||||
<option value="humidifier">Befeuchter / Entfeuchter</option>
|
<option value="humidifier">Befeuchter / Entfeuchter</option>
|
||||||
<option value="media_player">TV / Medien</option>
|
<option value="media_player">TV / Medien</option>
|
||||||
<option value="remote">Fernbedienungen</option>
|
<option value="remote">Fernbedienungen</option>
|
||||||
|
<option value="scene">Szenen</option>
|
||||||
<option value="number">Numerische Helper</option>
|
<option value="number">Numerische Helper</option>
|
||||||
<option value="valve">Ventile</option>
|
<option value="valve">Ventile</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -185,8 +186,21 @@ let currentActuatorId = null;
|
|||||||
let actuatorChoices = [];
|
let actuatorChoices = [];
|
||||||
let contextOptions = [];
|
let contextOptions = [];
|
||||||
let manualContextState = {options: [], selected: new Set()};
|
let manualContextState = {options: [], selected: new Set()};
|
||||||
|
let cachedActuators = null;
|
||||||
|
let cachedEntities = null;
|
||||||
|
let cachedDiscovery = null;
|
||||||
const ACTUATOR_RESULT_LIMIT = 50;
|
const ACTUATOR_RESULT_LIMIT = 50;
|
||||||
|
|
||||||
|
function uniqueValues(values) {
|
||||||
|
return [...new Set(values.filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateDashboardCache() {
|
||||||
|
cachedActuators = null;
|
||||||
|
cachedEntities = null;
|
||||||
|
cachedDiscovery = null;
|
||||||
|
}
|
||||||
|
|
||||||
async function api(path, options = {}) {
|
async function api(path, options = {}) {
|
||||||
const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options});
|
const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options});
|
||||||
const body = response.status === 204 ? null : await response.json().catch(() => ({}));
|
const body = response.status === 204 ? null : await response.json().catch(() => ({}));
|
||||||
@@ -195,7 +209,9 @@ async function api(path, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function lifecycleLabel(record) {
|
function lifecycleLabel(record) {
|
||||||
if (record.behavior.status === "trained") return "Kontext erkannt";
|
const behaviorStatus = record.behavior_status || record.behavior?.status;
|
||||||
|
const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status;
|
||||||
|
if (behaviorStatus === "trained") return "Kontext erkannt";
|
||||||
const labels = {
|
const labels = {
|
||||||
trained: "lernt",
|
trained: "lernt",
|
||||||
pending_history: "sammelt Historie",
|
pending_history: "sammelt Historie",
|
||||||
@@ -204,26 +220,32 @@ function lifecycleLabel(record) {
|
|||||||
archived: "wartet auf Kontext",
|
archived: "wartet auf Kontext",
|
||||||
orphaned: "Aktor nicht gefunden",
|
orphaned: "Aktor nicht gefunden",
|
||||||
};
|
};
|
||||||
return labels[record.lifecycle.status] || record.lifecycle.status;
|
return labels[lifecycleStatus] || lifecycleStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusClass(record) {
|
function statusClass(record) {
|
||||||
if (record.behavior.status === "trained") return "ok";
|
const behaviorStatus = record.behavior_status || record.behavior?.status;
|
||||||
if (record.lifecycle.status === "trained") return "ok";
|
const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status;
|
||||||
if (["pending_history", "pending_assignment", "archived"].includes(record.lifecycle.status)) return "warn";
|
if (behaviorStatus === "trained") return "ok";
|
||||||
|
if (lifecycleStatus === "trained") return "ok";
|
||||||
|
if (["pending_history", "pending_assignment", "archived"].includes(lifecycleStatus)) return "warn";
|
||||||
return "bad";
|
return "bad";
|
||||||
}
|
}
|
||||||
|
|
||||||
function behaviorLabel(record) {
|
function behaviorLabel(record) {
|
||||||
if (record.behavior.mode === "active") return "aktiv freigegeben";
|
const mode = record.behavior_mode || record.behavior?.mode;
|
||||||
if (record.behavior.status === "trained") return "Shadow-Vorhersage";
|
const status = record.behavior_status || record.behavior?.status;
|
||||||
if (record.behavior.status === "blocked") return "Lernen blockiert";
|
if (mode === "active") return "aktiv freigegeben";
|
||||||
|
if (status === "trained") return "Shadow-Vorhersage";
|
||||||
|
if (status === "blocked") return "Lernen blockiert";
|
||||||
return "sammelt Handlungen";
|
return "sammelt Handlungen";
|
||||||
}
|
}
|
||||||
|
|
||||||
function predictionLabel(record) {
|
function predictionLabel(record) {
|
||||||
return record.behavior.prediction
|
const target = record.prediction_target_state || record.behavior?.prediction?.target_state;
|
||||||
? `${record.behavior.prediction.target_state} (${Math.round(record.behavior.prediction.confidence * 100)} %)`
|
const confidence = record.prediction_confidence ?? record.behavior?.prediction?.confidence;
|
||||||
|
return target
|
||||||
|
? `${target} (${Math.round(confidence * 100)} %)`
|
||||||
: "Keine fällige Aktion";
|
: "Keine fällige Aktion";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,14 +273,33 @@ function matchesSearch(entity, query) {
|
|||||||
|
|
||||||
function categoryForEntity(entity) {
|
function categoryForEntity(entity) {
|
||||||
const cls = entity.device_class || "";
|
const cls = entity.device_class || "";
|
||||||
|
const text = normalizedSearch([
|
||||||
|
entity.entity_id,
|
||||||
|
entity.friendly_name,
|
||||||
|
entity.area_name,
|
||||||
|
entity.device_name,
|
||||||
|
].filter(Boolean).join(" "));
|
||||||
|
if (["pv", "solar", "akku", "batterie", "battery", "einspeisung", "wechselrichter"].some(token => text.includes(token))) {
|
||||||
|
return "PV / Akku / Einspeisung";
|
||||||
|
}
|
||||||
|
if (entity.domain === "fan") return "Lüftung / Ventilatoren";
|
||||||
|
if (entity.domain === "climate") return "Heizung / Klima";
|
||||||
|
if (entity.domain === "weather") return "Wetter";
|
||||||
|
if (entity.domain === "person" || entity.domain === "device_tracker") return "Anwesenheit / Personen";
|
||||||
|
if (entity.domain === "cover") return "Rollläden / Cover";
|
||||||
if (entity.domain === "light") return "Lichtzustände";
|
if (entity.domain === "light") return "Lichtzustände";
|
||||||
if (entity.domain === "switch") return "Schalter / Helper";
|
if (entity.domain === "switch") return "Schalter / Steckdosen";
|
||||||
|
if (entity.domain.startsWith("input_")) return "Helper";
|
||||||
|
if (entity.domain === "scene") return "Szenen";
|
||||||
|
if (entity.domain === "media_player" || entity.domain === "remote") return "TV / Medien";
|
||||||
if (["motion", "occupancy", "presence"].includes(cls)) return "PIR / Präsenz";
|
if (["motion", "occupancy", "presence"].includes(cls)) return "PIR / Präsenz";
|
||||||
if (["illuminance"].includes(cls)) return "Helligkeit";
|
if (["illuminance"].includes(cls)) return "Helligkeit";
|
||||||
if (["door", "garage_door", "opening", "window"].includes(cls)) return "Tür / Fenster";
|
if (["door", "garage_door", "opening", "window"].includes(cls)) return "Tür / Fenster";
|
||||||
|
if (["smoke", "safety", "problem"].includes(cls)) return "Sicherheit / Diagnose";
|
||||||
if (["humidity", "moisture"].includes(cls)) return "Luftfeuchtigkeit";
|
if (["humidity", "moisture"].includes(cls)) return "Luftfeuchtigkeit";
|
||||||
if (["temperature"].includes(cls)) return "Temperatur";
|
if (["temperature"].includes(cls)) return "Temperatur";
|
||||||
if (["power", "energy", "current", "voltage"].includes(cls)) return "Strom / Energie";
|
if (["power", "energy", "current", "voltage"].includes(cls)) return "Strom / Energie";
|
||||||
|
if (["battery", "signal_strength"].includes(cls)) return "Batterie / Signal";
|
||||||
if (entity.domain === "binary_sensor") return "Binäre Sensoren";
|
if (entity.domain === "binary_sensor") return "Binäre Sensoren";
|
||||||
if (entity.domain === "sensor") return "Weitere Messsensoren";
|
if (entity.domain === "sensor") return "Weitere Messsensoren";
|
||||||
return "Weitere Zustände";
|
return "Weitere Zustände";
|
||||||
@@ -305,18 +346,36 @@ async function loadOverview() {
|
|||||||
status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
|
status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
|
||||||
chips.innerHTML = "";
|
chips.innerHTML = "";
|
||||||
}
|
}
|
||||||
await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators()]);
|
await loadDashboardData();
|
||||||
|
renderActuatorDiscovery();
|
||||||
|
renderConfiguredActuators();
|
||||||
void loadActuatorSuggestions();
|
void loadActuatorSuggestions();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadDashboardData() {
|
||||||
|
const [actuators, entities, discovery] = await Promise.all([
|
||||||
|
api("v1/actuators/summary"),
|
||||||
|
Promise.resolve([]),
|
||||||
|
api("v1/actuators/discovery"),
|
||||||
|
]);
|
||||||
|
cachedActuators = actuators;
|
||||||
|
cachedEntities = entities;
|
||||||
|
cachedDiscovery = discovery;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadActuatorDiscovery() {
|
async function loadActuatorDiscovery() {
|
||||||
|
if (!cachedActuators || !cachedDiscovery) {
|
||||||
|
await loadDashboardData();
|
||||||
|
}
|
||||||
|
renderActuatorDiscovery();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActuatorDiscovery() {
|
||||||
const options = document.getElementById("actuator-options");
|
const options = document.getElementById("actuator-options");
|
||||||
const select = document.getElementById("actuator-select");
|
const select = document.getElementById("actuator-select");
|
||||||
try {
|
try {
|
||||||
const [available, configured] = await Promise.all([
|
const available = cachedDiscovery || [];
|
||||||
api("v1/actuators/discovery"),
|
const configured = cachedActuators || [];
|
||||||
api("v1/actuators"),
|
|
||||||
]);
|
|
||||||
const configuredIds = new Set(configured.map(record => record.actuator_entity_id));
|
const configuredIds = new Set(configured.map(record => record.actuator_entity_id));
|
||||||
actuatorChoices = available.filter(entity => !configuredIds.has(entity.entity_id));
|
actuatorChoices = available.filter(entity => !configuredIds.has(entity.entity_id));
|
||||||
options.innerHTML = actuatorChoices.slice(0, 120).map(entity =>
|
options.innerHTML = actuatorChoices.slice(0, 120).map(entity =>
|
||||||
@@ -366,6 +425,7 @@ function actuatorGroupLabel(domain) {
|
|||||||
media_player: "TV / Medien",
|
media_player: "TV / Medien",
|
||||||
number: "Numerische Helper",
|
number: "Numerische Helper",
|
||||||
remote: "Fernbedienungen",
|
remote: "Fernbedienungen",
|
||||||
|
scene: "Szenen",
|
||||||
switch: "Schalter / Steckdosen",
|
switch: "Schalter / Steckdosen",
|
||||||
cover: "Rollläden / Cover",
|
cover: "Rollläden / Cover",
|
||||||
fan: "Lüftung / Ventilatoren",
|
fan: "Lüftung / Ventilatoren",
|
||||||
@@ -463,37 +523,58 @@ async function configureActuator() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadConfiguredActuators() {
|
async function loadConfiguredActuators() {
|
||||||
|
if (!cachedActuators || !cachedEntities) {
|
||||||
|
await loadDashboardData();
|
||||||
|
}
|
||||||
|
renderConfiguredActuators();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConfiguredActuators() {
|
||||||
const box = document.getElementById("configured-actuators");
|
const box = document.getElementById("configured-actuators");
|
||||||
try {
|
try {
|
||||||
const rows = await api("v1/actuators");
|
const rows = cachedActuators || [];
|
||||||
|
const entities = cachedEntities || [];
|
||||||
|
const entityMap = new Map(entities.map(entity => [entity.entity_id, entity]));
|
||||||
|
const groups = new Map();
|
||||||
|
for (const record of rows) {
|
||||||
|
const entity = entityMap.get(record.actuator_entity_id) || {};
|
||||||
|
const group = entity.area_name || actuatorGroupLabel(record.actuator_entity_id.split(".", 1)[0]);
|
||||||
|
if (!groups.has(group)) groups.set(group, []);
|
||||||
|
groups.get(group).push({record, entity});
|
||||||
|
}
|
||||||
|
const groupedRows = [...groups.entries()].sort(([left], [right]) => left.localeCompare(right));
|
||||||
box.innerHTML = rows.length ? `
|
box.innerHTML = rows.length ? `
|
||||||
<div class="card-list">
|
${groupedRows.map(([group, items]) => `
|
||||||
${rows.map(record => `
|
<h3>${escapeHtml(group)}</h3>
|
||||||
|
<div class="card-list">
|
||||||
|
${items.map(({record, entity}) => `
|
||||||
<article class="actuator-card ${currentActuatorId === record.actuator_entity_id ? "selected" : ""}">
|
<article class="actuator-card ${currentActuatorId === record.actuator_entity_id ? "selected" : ""}">
|
||||||
<div class="card-title">
|
<div class="card-title">
|
||||||
<div>
|
<div>
|
||||||
|
<div><strong>${escapeHtml(entity.friendly_name || record.actuator_entity_id)}</strong></div>
|
||||||
<div class="entity-id">${escapeHtml(record.actuator_entity_id)}</div>
|
<div class="entity-id">${escapeHtml(record.actuator_entity_id)}</div>
|
||||||
<div class="${record.behavior.status === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</div>
|
<div class="${(record.behavior_status || record.behavior?.status) === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="chip">${escapeHtml(lifecycleLabel(record))}</span>
|
<span class="chip">${escapeHtml(lifecycleLabel(record))}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-grid">
|
<div class="metric-grid">
|
||||||
<div class="metric"><strong>Freigabe</strong><span class="${record.behavior.activation_ready ? "ok" : "warn"}">${escapeHtml(record.behavior.activation_ready ? "bereit" : record.behavior.activation_reason)}</span></div>
|
<div class="metric"><strong>Freigabe</strong><span class="${record.activation_ready ? "ok" : "warn"}">${escapeHtml(record.activation_ready ? "bereit" : record.activation_reason)}</span></div>
|
||||||
<div class="metric"><strong>Handlungen</strong>${record.behavior.sample_count}</div>
|
<div class="metric"><strong>Handlungen</strong>${record.sample_count}</div>
|
||||||
<div class="metric"><strong>Vorhersage</strong>${escapeHtml(predictionLabel(record))}</div>
|
<div class="metric"><strong>Vorhersage</strong>${escapeHtml(predictionLabel(record))}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button onclick="showActuator('${escapeHtml(record.actuator_entity_id)}')">Details öffnen</button>
|
<button onclick="showActuator('${escapeHtml(record.actuator_entity_id)}')">Details öffnen</button>
|
||||||
${record.behavior.mode === "active"
|
${record.behavior_mode === "active"
|
||||||
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">Stoppen + HA-Automationen fortsetzen</button>`
|
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">Stoppen + HA-Automationen fortsetzen</button>`
|
||||||
: record.behavior.activation_ready
|
: record.activation_ready
|
||||||
? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true, true, false)">SillyHome übernehmen lassen</button>`
|
? `<button onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', true, true, false)">SillyHome übernehmen lassen</button>`
|
||||||
: ""}
|
: ""}
|
||||||
<button class="danger" onclick="removeActuator('${escapeHtml(record.actuator_entity_id)}')">Entfernen</button>
|
<button class="danger" onclick="removeActuator('${escapeHtml(record.actuator_entity_id)}')">Entfernen</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
`).join("")}
|
`).join("")}
|
||||||
</div>` : "<p>Noch keine Aktoren ausgewählt.</p>";
|
</div>
|
||||||
|
`).join("")}` : "<p>Noch keine Aktoren ausgewählt.</p>";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
box.textContent = error.message;
|
box.textContent = error.message;
|
||||||
}
|
}
|
||||||
@@ -516,7 +597,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
const evidence = [...record.numeric_candidates, ...record.context_candidates]
|
const evidence = [...record.numeric_candidates, ...record.context_candidates]
|
||||||
.filter(candidate => contexts.includes(candidate.entity_id))
|
.filter(candidate => contexts.includes(candidate.entity_id))
|
||||||
.map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${candidate.evidence.map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`)
|
.map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${uniqueValues(candidate.evidence).map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`)
|
||||||
.join("");
|
.join("");
|
||||||
const currentContextControls = contexts.length
|
const currentContextControls = contexts.length
|
||||||
? `<ul>${contexts.map(entityId => `
|
? `<ul>${contexts.map(entityId => `
|
||||||
@@ -670,6 +751,7 @@ async function saveManualAssignment(actuatorId) {
|
|||||||
note: "Manuell im Dashboard gesetzt",
|
note: "Manuell im Dashboard gesetzt",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
invalidateDashboardCache();
|
||||||
await loadConfiguredActuators();
|
await loadConfiguredActuators();
|
||||||
await showActuator(actuatorId, "Manuelle Kontext-Auswahl gespeichert.");
|
await showActuator(actuatorId, "Manuelle Kontext-Auswahl gespeichert.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -693,6 +775,7 @@ async function removeContextEntity(actuatorId, entityId) {
|
|||||||
note: `Entity ${entityId} entfernt`,
|
note: `Entity ${entityId} entfernt`,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
invalidateDashboardCache();
|
||||||
await loadConfiguredActuators();
|
await loadConfiguredActuators();
|
||||||
await showActuator(actuatorId, "Kontext-Entity entfernt.");
|
await showActuator(actuatorId, "Kontext-Entity entfernt.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -717,6 +800,7 @@ async function evaluateActuator(actuatorId) {
|
|||||||
const message = record.behavior.prediction
|
const message = record.behavior.prediction
|
||||||
? `Prüfung ${checkedAt}: ${record.behavior.prediction.target_state} mit ${Math.round(record.behavior.prediction.confidence * 100)} % vorhergesagt.`
|
? `Prüfung ${checkedAt}: ${record.behavior.prediction.target_state} mit ${Math.round(record.behavior.prediction.confidence * 100)} % vorhergesagt.`
|
||||||
: `Prüfung ${checkedAt}: Kein frischer passender Sensorwechsel erkannt; aktuell ist keine Aktion fällig.`;
|
: `Prüfung ${checkedAt}: Kein frischer passender Sensorwechsel erkannt; aktuell ist keine Aktion fällig.`;
|
||||||
|
invalidateDashboardCache();
|
||||||
await loadConfiguredActuators();
|
await loadConfiguredActuators();
|
||||||
await showActuator(actuatorId, message);
|
await showActuator(actuatorId, message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -734,6 +818,7 @@ async function sendFeedback(actuatorId, correct) {
|
|||||||
expected_state: expectedState || null,
|
expected_state: expectedState || null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
invalidateDashboardCache();
|
||||||
await loadConfiguredActuators();
|
await loadConfiguredActuators();
|
||||||
await showActuator(actuatorId, correct ? "Vorhersage als korrekt gelernt." : "Vorhersage als falsch markiert.");
|
await showActuator(actuatorId, correct ? "Vorhersage als korrekt gelernt." : "Vorhersage als falsch markiert.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -759,6 +844,7 @@ async function setActivation(actuatorId, active, pauseMatchingAutomations, resto
|
|||||||
restore_paused_automations: restorePausedAutomations,
|
restore_paused_automations: restorePausedAutomations,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
invalidateDashboardCache();
|
||||||
await loadConfiguredActuators();
|
await loadConfiguredActuators();
|
||||||
await showActuator(actuatorId);
|
await showActuator(actuatorId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -777,6 +863,7 @@ async function setRelatedAutomation(actuatorId, automationEntityId, enabled) {
|
|||||||
enabled,
|
enabled,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
invalidateDashboardCache();
|
||||||
await loadConfiguredActuators();
|
await loadConfiguredActuators();
|
||||||
await showActuator(actuatorId);
|
await showActuator(actuatorId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "sillyhome-next"
|
name = "sillyhome-next"
|
||||||
version = "0.7.14"
|
version = "0.7.20"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -310,6 +310,48 @@ def test_reconciliation_does_not_auto_select_overload_sensors_by_power_area(
|
|||||||
assert all(candidate.auto_accepted is False for candidate in record.context_candidates)
|
assert all(candidate.auto_accepted is False for candidate in record.context_candidates)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fan_prefers_humidity_over_power_sensor(tmp_path: Path) -> None:
|
||||||
|
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||||
|
entities = [
|
||||||
|
HaEntitySummary(
|
||||||
|
entity_id="fan.bad_lueftung",
|
||||||
|
domain="fan",
|
||||||
|
friendly_name="Bad Lüftung",
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
HaEntitySummary(
|
||||||
|
entity_id="sensor.bad_power",
|
||||||
|
domain="sensor",
|
||||||
|
device_class="power",
|
||||||
|
state_class="measurement",
|
||||||
|
unit_of_measurement="W",
|
||||||
|
friendly_name="Bad Leistung",
|
||||||
|
area_name="Bad",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
service = _service(
|
||||||
|
tmp_path,
|
||||||
|
entities,
|
||||||
|
{
|
||||||
|
"sensor.bad_luftfeuchtigkeit": _points(8, start, 55.0),
|
||||||
|
"sensor.bad_power": _points(8, start, 5.0),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
record = service.configure_actuator("fan.bad_lueftung")
|
||||||
|
|
||||||
|
assert record.assignment.selected_numeric_entity_id == "sensor.bad_luftfeuchtigkeit"
|
||||||
|
|
||||||
|
|
||||||
def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Path) -> None:
|
def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Path) -> None:
|
||||||
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||||
entities = [
|
entities = [
|
||||||
@@ -358,3 +400,39 @@ def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Pa
|
|||||||
assert record.assignment.selected_context_entity_ids == ["sensor.abstellkammer_illuminance"]
|
assert record.assignment.selected_context_entity_ids == ["sensor.abstellkammer_illuminance"]
|
||||||
assert record.assignment.source is AssignmentSource.MANUAL
|
assert record.assignment.source is AssignmentSource.MANUAL
|
||||||
assert record.manual_override is not None
|
assert record.manual_override is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_assignment_evidence_is_not_duplicated(tmp_path: Path) -> None:
|
||||||
|
entities = [
|
||||||
|
HaEntitySummary(
|
||||||
|
entity_id="light.abstellkammer",
|
||||||
|
domain="light",
|
||||||
|
friendly_name="Abstellkammer Licht",
|
||||||
|
area_name="Abstellkammer",
|
||||||
|
),
|
||||||
|
HaEntitySummary(
|
||||||
|
entity_id="binary_sensor.abstellkammer_motion",
|
||||||
|
domain="binary_sensor",
|
||||||
|
device_class="motion",
|
||||||
|
friendly_name="Abstellkammer Bewegung",
|
||||||
|
area_name="Abstellkammer",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
service = _service(tmp_path, entities, {})
|
||||||
|
service.configure_actuator("light.abstellkammer")
|
||||||
|
for _ in range(3):
|
||||||
|
service.set_manual_assignment(
|
||||||
|
"light.abstellkammer",
|
||||||
|
numeric_entity_id=None,
|
||||||
|
context_entity_ids=["binary_sensor.abstellkammer_motion"],
|
||||||
|
note="Manuell gesetzt",
|
||||||
|
)
|
||||||
|
|
||||||
|
record = service.get_actuator("light.abstellkammer")
|
||||||
|
candidate = next(
|
||||||
|
item
|
||||||
|
for item in record.context_candidates
|
||||||
|
if item.entity_id == "binary_sensor.abstellkammer_motion"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert candidate.evidence.count("Manuell vom Nutzer als relevant festgelegt.") == 1
|
||||||
|
|||||||
@@ -32,5 +32,7 @@ def test_dashboard_is_served_at_root() -> None:
|
|||||||
assert "Kein frischer passender Sensorwechsel erkannt" in response.text
|
assert "Kein frischer passender Sensorwechsel erkannt" in response.text
|
||||||
assert "Vorhersage jetzt prüfen" not in response.text
|
assert "Vorhersage jetzt prüfen" not in response.text
|
||||||
assert "record.behavior.activation_ready" in response.text
|
assert "record.behavior.activation_ready" in response.text
|
||||||
|
assert "record.behavior.status ===" not in response.text
|
||||||
|
assert "record.behavior_status || record.behavior?.status" in response.text
|
||||||
assert "Automation-Entwurf" not in response.text
|
assert "Automation-Entwurf" not in response.text
|
||||||
assert "Manuelle Overrides" not in response.text
|
assert "Manuelle Overrides" not in response.text
|
||||||
|
|||||||
@@ -94,7 +94,8 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
connect.assert_called_once_with(
|
connect.assert_called_once_with(
|
||||||
"ws://homeassistant:8123/api/websocket",
|
"ws://homeassistant:8123/api/websocket",
|
||||||
ping_interval=None,
|
ping_interval=20,
|
||||||
|
ping_timeout=10,
|
||||||
)
|
)
|
||||||
assert fake_ws.sent == [
|
assert fake_ws.sent == [
|
||||||
{"type": "auth", "access_token": "test-token"},
|
{"type": "auth", "access_token": "test-token"},
|
||||||
@@ -110,6 +111,7 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
|
|||||||
mock_app.state.behavior_engine = mock_engine
|
mock_app.state.behavior_engine = mock_engine
|
||||||
mock_app.state.ha_reader = _FakeHaReader()
|
mock_app.state.ha_reader = _FakeHaReader()
|
||||||
mock_store = ActuatorStore(tmp_path / "store")
|
mock_store = ActuatorStore(tmp_path / "store")
|
||||||
|
mock_store.configure("light.test")
|
||||||
mock_app.state.actuator_store = mock_store
|
mock_app.state.actuator_store = mock_store
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user