Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9db7cde179 | |||
| 3140f65527 | |||
| 5727053951 | |||
| 658516cd96 | |||
| 8cd8f3e3b7 |
32
CHANGELOG.md
32
CHANGELOG.md
@@ -1,5 +1,37 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.7.8 - 2026-06-15
|
||||||
|
- Home-Assistant-WebSocket-Listener deaktiviert den clientseitigen Keepalive-
|
||||||
|
Ping, damit stabile HA-Verbindungen nicht durch Ping-Timeouts ständig neu
|
||||||
|
aufgebaut werden
|
||||||
|
- Fallback-Auswertung läuft bei getrenntem WebSocket kurzfristig alle 5 Sekunden,
|
||||||
|
damit übernommene Aktoren nicht ohne Steuerung bleiben
|
||||||
|
|
||||||
|
## 0.7.7 - 2026-06-15
|
||||||
|
- WebSocket-State-Changes lesen jetzt das echte Home-Assistant-Eventformat
|
||||||
|
(`event.data.entity_id`), damit Kontextwechsel wie Türsensoren sofort
|
||||||
|
Vorhersagen und Schaltungen auslösen statt erst beim nächsten Statusabruf
|
||||||
|
|
||||||
|
## 0.7.6 - 2026-06-14
|
||||||
|
- Kontextvorschläge blenden zusätzlich Batterie-, Status-, Node-, Last-Seen-
|
||||||
|
und Basic-Entities aus, sofern sie nicht bewusst manuell ausgewählt wurden
|
||||||
|
|
||||||
|
## 0.7.5 - 2026-06-14
|
||||||
|
- Kontextvorschläge weiter geschärft: Standardliste zeigt nur gleiche Räume,
|
||||||
|
gemeinsame Geräte/Tokens oder echte globale Außenwerte
|
||||||
|
- Diagnosewerte wie MQTT-, WiFi-, Restart- und Connect-Zähler werden nicht mehr
|
||||||
|
als fachliche Kontextvorschläge angeboten
|
||||||
|
|
||||||
|
## 0.7.4 - 2026-06-14
|
||||||
|
- Kontext-Auswahl liefert jetzt aktorbezogene Vorschläge statt einer pauschalen
|
||||||
|
Roh-Liste aller Sensoren und Zustände
|
||||||
|
- Dashboard-Auswahl für Aktoren und Kontext nach Typ/Kategorie gruppiert und
|
||||||
|
durchsuchbar; lange Listen werden begrenzt statt mobil unbedienbar zu werden
|
||||||
|
- Manuelle Entity-ID-Eingabe ergänzt, damit relevante Sensoren auch ohne
|
||||||
|
Dropdown-Treffer gespeichert werden können
|
||||||
|
- Irrelevante System-/VPN-/pfSense-Sensoren tauchen bei Lichtaktoren ohne
|
||||||
|
fachlichen Bezug nicht mehr als Standardvorschläge auf
|
||||||
|
|
||||||
## 0.7.3 - 2026-06-14
|
## 0.7.3 - 2026-06-14
|
||||||
- Automatische Kontextzuordnung ignoriert generische Bereiche wie `Monitoring`,
|
- Automatische Kontextzuordnung ignoriert generische Bereiche wie `Monitoring`,
|
||||||
damit System-/Disk-/Überhitzungssensoren nicht fälschlich Lichtaktoren erklären
|
damit System-/Disk-/Überhitzungssensoren nicht fälschlich Lichtaktoren erklären
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: SillyHome Next
|
name: SillyHome Next
|
||||||
version: "0.7.3"
|
version: "0.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
|
||||||
|
|||||||
@@ -65,6 +65,45 @@ _CONTEXT_AUTO_ACCEPT_SCORE = 0.78
|
|||||||
_CONTEXT_AUTO_ACCEPT_MIN_SCORE = 0.3
|
_CONTEXT_AUTO_ACCEPT_MIN_SCORE = 0.3
|
||||||
_MAX_CONTEXT_SELECTIONS = 5
|
_MAX_CONTEXT_SELECTIONS = 5
|
||||||
_AUDIT_LIMIT = 20
|
_AUDIT_LIMIT = 20
|
||||||
|
_MANUAL_CONTEXT_DOMAINS = frozenset({
|
||||||
|
"binary_sensor",
|
||||||
|
"climate",
|
||||||
|
"cover",
|
||||||
|
"device_tracker",
|
||||||
|
"fan",
|
||||||
|
"humidifier",
|
||||||
|
"light",
|
||||||
|
"person",
|
||||||
|
"sensor",
|
||||||
|
"switch",
|
||||||
|
"weather",
|
||||||
|
})
|
||||||
|
_CONTEXT_SUGGESTION_LIMIT = 120
|
||||||
|
_OUTDOOR_TOKENS = frozenset({"aussen", "außen", "outdoor", "garten", "terrasse", "balkon"})
|
||||||
|
_DIAGNOSTIC_TOKENS = frozenset({
|
||||||
|
"basic",
|
||||||
|
"battery",
|
||||||
|
"connect",
|
||||||
|
"count",
|
||||||
|
"diagnostic",
|
||||||
|
"firmware",
|
||||||
|
"gesehen",
|
||||||
|
"last",
|
||||||
|
"linkquality",
|
||||||
|
"knoten",
|
||||||
|
"knotens",
|
||||||
|
"mqtt",
|
||||||
|
"node",
|
||||||
|
"reason",
|
||||||
|
"restart",
|
||||||
|
"rssi",
|
||||||
|
"signal",
|
||||||
|
"ssid",
|
||||||
|
"status",
|
||||||
|
"uptime",
|
||||||
|
"wifi",
|
||||||
|
"zuletzt",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class ActuatorReconciliationService:
|
class ActuatorReconciliationService:
|
||||||
@@ -91,6 +130,51 @@ class ActuatorReconciliationService:
|
|||||||
def get_actuator(self, actuator_entity_id: str) -> ActuatorRecord:
|
def get_actuator(self, actuator_entity_id: str) -> ActuatorRecord:
|
||||||
return self._store.get(actuator_entity_id)
|
return self._store.get(actuator_entity_id)
|
||||||
|
|
||||||
|
def suggest_context_options(
|
||||||
|
self,
|
||||||
|
actuator_entity_id: str,
|
||||||
|
*,
|
||||||
|
limit: int = _CONTEXT_SUGGESTION_LIMIT,
|
||||||
|
) -> list[HaEntitySummary]:
|
||||||
|
entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()}
|
||||||
|
discovered = {entity.entity_id: entity for entity in self._ha_reader.discover()}
|
||||||
|
actuator = entities.get(actuator_entity_id)
|
||||||
|
if actuator is None:
|
||||||
|
raise KeyError("Aktuator-Konfiguration nicht gefunden.")
|
||||||
|
selected_ids = _selected_context_ids(self._store.get(actuator_entity_id))
|
||||||
|
ranked: list[tuple[float, str, HaEntitySummary]] = []
|
||||||
|
for entity in entities.values():
|
||||||
|
if entity.entity_id == actuator_entity_id or entity.domain not in _MANUAL_CONTEXT_DOMAINS:
|
||||||
|
continue
|
||||||
|
role = _manual_context_role(entity, discovered.get(entity.entity_id))
|
||||||
|
score, _ = _score_candidate(
|
||||||
|
actuator,
|
||||||
|
entity,
|
||||||
|
role,
|
||||||
|
context=role is not EntityRole.MEASUREMENT,
|
||||||
|
)
|
||||||
|
selected = entity.entity_id in selected_ids
|
||||||
|
if selected:
|
||||||
|
score = max(score, 1.0)
|
||||||
|
if not selected and (
|
||||||
|
_is_diagnostic_context(entity)
|
||||||
|
or not _has_context_relationship(actuator, entity)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if not selected and score < 0.1:
|
||||||
|
continue
|
||||||
|
ranked.append((score, _context_sort_group(entity), entity))
|
||||||
|
ranked.sort(
|
||||||
|
key=lambda item: (
|
||||||
|
-item[0],
|
||||||
|
item[1],
|
||||||
|
item[2].area_name or "",
|
||||||
|
item[2].friendly_name or item[2].entity_id,
|
||||||
|
item[2].entity_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return [entity for _, _, entity in ranked[:limit]]
|
||||||
|
|
||||||
def delete_actuator(self, actuator_entity_id: str) -> None:
|
def delete_actuator(self, actuator_entity_id: str) -> None:
|
||||||
model_id = model_id_for_actuator(actuator_entity_id)
|
model_id = model_id_for_actuator(actuator_entity_id)
|
||||||
self._registry.archive(model_id)
|
self._registry.archive(model_id)
|
||||||
@@ -592,6 +676,73 @@ def _filter_candidates(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _selected_context_ids(record: ActuatorRecord) -> set[str]:
|
||||||
|
result = set(record.assignment.selected_context_entity_ids)
|
||||||
|
if record.assignment.selected_numeric_entity_id:
|
||||||
|
result.add(record.assignment.selected_numeric_entity_id)
|
||||||
|
if record.manual_override is not None:
|
||||||
|
result.update(record.manual_override.context_entity_ids)
|
||||||
|
if record.manual_override.numeric_entity_id:
|
||||||
|
result.add(record.manual_override.numeric_entity_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _manual_context_role(
|
||||||
|
entity: HaEntitySummary,
|
||||||
|
discovered: DiscoveredEntity | None,
|
||||||
|
) -> EntityRole:
|
||||||
|
if discovered is not None and discovered.role is not EntityRole.UNSUPPORTED:
|
||||||
|
return discovered.role
|
||||||
|
if entity.domain == "sensor":
|
||||||
|
return EntityRole.MEASUREMENT
|
||||||
|
if entity.domain == "binary_sensor":
|
||||||
|
return EntityRole.BINARY_CONTEXT
|
||||||
|
return EntityRole.CONTEXT
|
||||||
|
|
||||||
|
|
||||||
|
def _context_sort_group(entity: HaEntitySummary) -> str:
|
||||||
|
device_class = entity.device_class or ""
|
||||||
|
if device_class in {"motion", "occupancy", "presence"}:
|
||||||
|
return "01_presence"
|
||||||
|
if device_class in {"illuminance"}:
|
||||||
|
return "02_brightness"
|
||||||
|
if device_class in {"door", "garage_door", "opening", "window"}:
|
||||||
|
return "03_opening"
|
||||||
|
if device_class in {"humidity", "moisture"}:
|
||||||
|
return "04_humidity"
|
||||||
|
if device_class in {"power", "energy", "current", "voltage"}:
|
||||||
|
return "05_power"
|
||||||
|
if entity.domain in {"light", "switch"}:
|
||||||
|
return "06_states"
|
||||||
|
return f"20_{entity.domain}_{device_class}"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_diagnostic_context(entity: HaEntitySummary) -> bool:
|
||||||
|
tokens = _metadata_tokens(entity, include_stopwords=True)
|
||||||
|
return bool(tokens.intersection(_DIAGNOSTIC_TOKENS))
|
||||||
|
|
||||||
|
|
||||||
|
def _has_context_relationship(actuator: HaEntitySummary, entity: HaEntitySummary) -> bool:
|
||||||
|
if (
|
||||||
|
actuator.area_name
|
||||||
|
and entity.area_name
|
||||||
|
and actuator.area_name == entity.area_name
|
||||||
|
and actuator.area_name.lower() not in _GENERIC_AREA_NAMES
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
if actuator.device_id and entity.device_id and actuator.device_id == entity.device_id:
|
||||||
|
return True
|
||||||
|
if actuator.device_name and entity.device_name and actuator.device_name == entity.device_name:
|
||||||
|
return True
|
||||||
|
if _metadata_tokens(actuator).intersection(_metadata_tokens(entity)):
|
||||||
|
return True
|
||||||
|
entity_tokens = _metadata_tokens(entity, include_stopwords=True)
|
||||||
|
return bool(
|
||||||
|
entity_tokens.intersection(_OUTDOOR_TOKENS)
|
||||||
|
and entity.device_class in {"illuminance", "humidity", "temperature"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _score_candidate(
|
def _score_candidate(
|
||||||
actuator: HaEntitySummary,
|
actuator: HaEntitySummary,
|
||||||
entity: HaEntitySummary,
|
entity: HaEntitySummary,
|
||||||
@@ -637,6 +788,13 @@ def _score_candidate(
|
|||||||
if context and role is EntityRole.BINARY_CONTEXT:
|
if context and role is EntityRole.BINARY_CONTEXT:
|
||||||
score += 0.05
|
score += 0.05
|
||||||
evidence.append("Binärer Kontextsensor bevorzugt für Zusatzkontext.")
|
evidence.append("Binärer Kontextsensor bevorzugt für Zusatzkontext.")
|
||||||
|
if entity_tokens.intersection(_OUTDOOR_TOKENS) and entity.device_class in {
|
||||||
|
"illuminance",
|
||||||
|
"humidity",
|
||||||
|
"temperature",
|
||||||
|
}:
|
||||||
|
score += 0.1
|
||||||
|
evidence.append("Außenmesswert ist oft als übergreifender Kontext relevant.")
|
||||||
return round(min(score, 1.0), 4), evidence
|
return round(min(score, 1.0), 4), evidence
|
||||||
|
|
||||||
|
|
||||||
@@ -698,7 +856,7 @@ def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]:
|
|||||||
return frozenset(mapping.get(domain, {"power", "energy", "temperature"}))
|
return frozenset(mapping.get(domain, {"power", "energy", "temperature"}))
|
||||||
|
|
||||||
|
|
||||||
def _metadata_tokens(entity: HaEntitySummary) -> set[str]:
|
def _metadata_tokens(entity: HaEntitySummary, *, include_stopwords: bool = False) -> set[str]:
|
||||||
raw_values = [
|
raw_values = [
|
||||||
entity.entity_id,
|
entity.entity_id,
|
||||||
entity.friendly_name,
|
entity.friendly_name,
|
||||||
@@ -710,7 +868,7 @@ def _metadata_tokens(entity: HaEntitySummary) -> set[str]:
|
|||||||
if value is None:
|
if value is None:
|
||||||
continue
|
continue
|
||||||
for token in _TOKEN_PATTERN.findall(value.lower().replace("_", " ")):
|
for token in _TOKEN_PATTERN.findall(value.lower().replace("_", " ")):
|
||||||
if len(token) < 3 or token in _STOPWORDS:
|
if len(token) < 3 or (not include_stopwords and token in _STOPWORDS):
|
||||||
continue
|
continue
|
||||||
tokens.add(token)
|
tokens.add(token)
|
||||||
return tokens
|
return tokens
|
||||||
|
|||||||
@@ -14,19 +14,6 @@ from app.ha.models import HaEntitySummary
|
|||||||
from app.ha.reader import HaReader
|
from app.ha.reader import HaReader
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1/actuators", tags=["actuators"])
|
router = APIRouter(prefix="/v1/actuators", tags=["actuators"])
|
||||||
_MANUAL_CONTEXT_DOMAINS = frozenset({
|
|
||||||
"binary_sensor",
|
|
||||||
"climate",
|
|
||||||
"cover",
|
|
||||||
"device_tracker",
|
|
||||||
"fan",
|
|
||||||
"humidifier",
|
|
||||||
"light",
|
|
||||||
"person",
|
|
||||||
"sensor",
|
|
||||||
"switch",
|
|
||||||
"weather",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigureActuatorRequest(BaseModel):
|
class ConfigureActuatorRequest(BaseModel):
|
||||||
@@ -62,19 +49,16 @@ def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaE
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/context-options", response_model=list[HaEntitySummary])
|
@router.get("/context-options", response_model=list[HaEntitySummary])
|
||||||
def context_options(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaEntitySummary]:
|
def context_options(
|
||||||
return sorted(
|
request: Request,
|
||||||
[
|
actuator_entity_id: str | None = Query(default=None, pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$"),
|
||||||
entity
|
) -> list[HaEntitySummary]:
|
||||||
for entity in ha_reader.read_entities()
|
if actuator_entity_id is None:
|
||||||
if entity.domain in _MANUAL_CONTEXT_DOMAINS
|
return []
|
||||||
],
|
try:
|
||||||
key=lambda entity: (
|
return _service(request).suggest_context_options(actuator_entity_id)
|
||||||
entity.area_name or "",
|
except KeyError as exc:
|
||||||
entity.friendly_name or entity.entity_id,
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
entity.entity_id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ActuatorRecord])
|
@router.get("", response_model=list[ActuatorRecord])
|
||||||
|
|||||||
24
app/main.py
24
app/main.py
@@ -100,7 +100,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.3",
|
version="0.7.8",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
app.state.settings = load_settings()
|
app.state.settings = load_settings()
|
||||||
@@ -171,7 +171,7 @@ 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) as websocket:
|
async with websockets.connect(ws_url, ping_interval=None) 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":
|
||||||
@@ -213,12 +213,20 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
|
|||||||
event = data.get("event", {})
|
event = data.get("event", {})
|
||||||
if event.get("event_type") != "state_changed":
|
if event.get("event_type") != "state_changed":
|
||||||
continue
|
continue
|
||||||
entity_id = event.get("entity_id")
|
event_data = event.get("data", {})
|
||||||
|
if not isinstance(event_data, dict):
|
||||||
|
logger.warning("State-Changed-Event ohne gültige Daten empfangen")
|
||||||
|
continue
|
||||||
|
entity_id = event_data.get("entity_id")
|
||||||
if not entity_id:
|
if not entity_id:
|
||||||
continue
|
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(engine.handle_state_change, entity_id, event.get("new_state"))
|
await asyncio.to_thread(
|
||||||
|
engine.handle_state_change,
|
||||||
|
entity_id,
|
||||||
|
event_data.get("new_state"),
|
||||||
|
)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
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:
|
||||||
@@ -244,7 +252,13 @@ async def _fallback_prediction(app: FastAPI) -> None:
|
|||||||
Dies verhindert kompletten Ausfall der Vorhersagen bei Netzwerkproblemen.
|
Dies verhindert kompletten Ausfall der Vorhersagen bei Netzwerkproblemen.
|
||||||
"""
|
"""
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(app.state.settings.prediction_interval_seconds)
|
ws_status = getattr(app.state, "ws_status", None)
|
||||||
|
websocket_connected = ws_status is not None and ws_status.status == "connected"
|
||||||
|
await asyncio.sleep(
|
||||||
|
app.state.settings.prediction_interval_seconds
|
||||||
|
if websocket_connected
|
||||||
|
else min(5, app.state.settings.prediction_interval_seconds)
|
||||||
|
)
|
||||||
# Nur ausführen, wenn WebSocket nicht verbunden ist
|
# Nur ausführen, wenn WebSocket nicht verbunden ist
|
||||||
ws_status = getattr(app.state, "ws_status", None)
|
ws_status = getattr(app.state, "ws_status", None)
|
||||||
if ws_status is None or ws_status.status != "connected":
|
if ws_status is None or ws_status.status != "connected":
|
||||||
|
|||||||
@@ -51,6 +51,10 @@
|
|||||||
.actions button { flex:1 1 180px; margin-top:0; }
|
.actions button { flex:1 1 180px; margin-top:0; }
|
||||||
.detail-header { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; flex-wrap:wrap; }
|
.detail-header { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; flex-wrap:wrap; }
|
||||||
.manual-context { margin-top:14px; background:#111a23; border:1px solid #31404d; border-radius:14px; padding:14px; }
|
.manual-context { margin-top:14px; background:#111a23; border:1px solid #31404d; border-radius:14px; padding:14px; }
|
||||||
|
.inline-controls { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:8px; margin:8px 0; }
|
||||||
|
.manual-entry { min-height:80px; resize:vertical; }
|
||||||
|
textarea { box-sizing:border-box; width:100%; border-radius:10px; border:1px solid #3b4b5b; padding:12px; background:#101820; color:#fff; font:inherit; }
|
||||||
|
optgroup { color:#cfe0ec; background:#101820; }
|
||||||
code { color:#cfe0ec; overflow-wrap:anywhere; }
|
code { color:#cfe0ec; overflow-wrap:anywhere; }
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
header { padding:18px 14px; }
|
header { padding:18px 14px; }
|
||||||
@@ -123,6 +127,23 @@
|
|||||||
<label for="actuator-input">Entitätsname oder Gerät aus Home Assistant</label>
|
<label for="actuator-input">Entitätsname oder Gerät aus Home Assistant</label>
|
||||||
<input id="actuator-input" list="actuator-options" placeholder="z. B. light.licht_abstellraum" autocomplete="off">
|
<input id="actuator-input" list="actuator-options" placeholder="z. B. light.licht_abstellraum" autocomplete="off">
|
||||||
<datalist id="actuator-options"></datalist>
|
<datalist id="actuator-options"></datalist>
|
||||||
|
<div class="inline-controls">
|
||||||
|
<div>
|
||||||
|
<label for="actuator-domain-filter">Typ</label>
|
||||||
|
<select id="actuator-domain-filter" onchange="renderActuatorSelect()">
|
||||||
|
<option value="">Alle steuerbaren Typen</option>
|
||||||
|
<option value="light">Lichter</option>
|
||||||
|
<option value="switch">Schalter / Helper</option>
|
||||||
|
<option value="cover">Rollläden / Cover</option>
|
||||||
|
<option value="fan">Lüftung / Ventilatoren</option>
|
||||||
|
<option value="humidifier">Befeuchter / Entfeuchter</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="actuator-search">Liste durchsuchen</label>
|
||||||
|
<input id="actuator-search" placeholder="Raum, Gerät oder Entity" oninput="renderActuatorSelect()" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label for="actuator-select">Oder aus Liste wählen</label>
|
<label for="actuator-select">Oder aus Liste wählen</label>
|
||||||
<select id="actuator-select" onchange="selectActuatorFromList()">
|
<select id="actuator-select" onchange="selectActuatorFromList()">
|
||||||
<option value="">Geräteliste wird geladen ...</option>
|
<option value="">Geräteliste wird geladen ...</option>
|
||||||
@@ -153,6 +174,7 @@ const escapeHtml = value => String(value ?? "")
|
|||||||
let currentActuatorId = null;
|
let currentActuatorId = null;
|
||||||
let actuatorChoices = [];
|
let actuatorChoices = [];
|
||||||
let contextOptions = [];
|
let contextOptions = [];
|
||||||
|
let manualContextState = {options: [], selected: new Set()};
|
||||||
|
|
||||||
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});
|
||||||
@@ -194,6 +216,61 @@ function predictionLabel(record) {
|
|||||||
: "Keine fällige Aktion";
|
: "Keine fällige Aktion";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function entityLabel(entity) {
|
||||||
|
const area = entity.area_name || "Ohne Bereich";
|
||||||
|
const name = entity.friendly_name || entity.entity_id;
|
||||||
|
return `${area} - ${name} (${entity.entity_id})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedSearch(value) {
|
||||||
|
return String(value || "").toLowerCase().replaceAll("_", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesSearch(entity, query) {
|
||||||
|
if (!query) return true;
|
||||||
|
return normalizedSearch([
|
||||||
|
entity.entity_id,
|
||||||
|
entity.friendly_name,
|
||||||
|
entity.area_name,
|
||||||
|
entity.device_name,
|
||||||
|
entity.device_class,
|
||||||
|
entity.domain,
|
||||||
|
].filter(Boolean).join(" ")).includes(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryForEntity(entity) {
|
||||||
|
const cls = entity.device_class || "";
|
||||||
|
if (entity.domain === "light") return "Lichtzustände";
|
||||||
|
if (entity.domain === "switch") return "Schalter / Helper";
|
||||||
|
if (["motion", "occupancy", "presence"].includes(cls)) return "PIR / Präsenz";
|
||||||
|
if (["illuminance"].includes(cls)) return "Helligkeit";
|
||||||
|
if (["door", "garage_door", "opening", "window"].includes(cls)) return "Tür / Fenster";
|
||||||
|
if (["humidity", "moisture"].includes(cls)) return "Luftfeuchtigkeit";
|
||||||
|
if (["temperature"].includes(cls)) return "Temperatur";
|
||||||
|
if (["power", "energy", "current", "voltage"].includes(cls)) return "Strom / Energie";
|
||||||
|
if (entity.domain === "binary_sensor") return "Binäre Sensoren";
|
||||||
|
if (entity.domain === "sensor") return "Weitere Messsensoren";
|
||||||
|
return "Weitere Zustände";
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionGroups(entities, selectedIds = new Set()) {
|
||||||
|
const groups = new Map();
|
||||||
|
for (const entity of entities) {
|
||||||
|
const category = categoryForEntity(entity);
|
||||||
|
if (!groups.has(category)) groups.set(category, []);
|
||||||
|
groups.get(category).push(entity);
|
||||||
|
}
|
||||||
|
return Array.from(groups.entries()).map(([label, items]) => `
|
||||||
|
<optgroup label="${escapeHtml(label)}">
|
||||||
|
${items.map(entity => `
|
||||||
|
<option value="${escapeHtml(entity.entity_id)}" ${selectedIds.has(entity.entity_id) ? "selected" : ""}>
|
||||||
|
${escapeHtml(entityLabel(entity))}
|
||||||
|
</option>
|
||||||
|
`).join("")}
|
||||||
|
</optgroup>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
async function loadOverview() {
|
async function loadOverview() {
|
||||||
const status = document.getElementById("status");
|
const status = document.getElementById("status");
|
||||||
const chips = document.getElementById("status-chips");
|
const chips = document.getElementById("status-chips");
|
||||||
@@ -217,7 +294,7 @@ 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(), loadContextOptions(), loadConfiguredActuators()]);
|
await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadActuatorDiscovery() {
|
async function loadActuatorDiscovery() {
|
||||||
@@ -230,24 +307,53 @@ async function loadActuatorDiscovery() {
|
|||||||
]);
|
]);
|
||||||
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.map(entity =>
|
options.innerHTML = actuatorChoices.slice(0, 120).map(entity =>
|
||||||
`<option value="${escapeHtml(entity.entity_id)}">${escapeHtml(entity.friendly_name || entity.entity_id)}${entity.area_name ? ` (${escapeHtml(entity.area_name)})` : ""}</option>`
|
`<option value="${escapeHtml(entity.entity_id)}">${escapeHtml(entity.friendly_name || entity.entity_id)}${entity.area_name ? ` (${escapeHtml(entity.area_name)})` : ""}</option>`
|
||||||
).join("");
|
).join("");
|
||||||
select.innerHTML = [
|
renderActuatorSelect();
|
||||||
`<option value="">Gerät auswählen ...</option>`,
|
|
||||||
...actuatorChoices.map(entity =>
|
|
||||||
`<option value="${escapeHtml(entity.entity_id)}">${escapeHtml(entity.area_name || "Ohne Bereich")} - ${escapeHtml(entity.friendly_name || entity.entity_id)} (${escapeHtml(entity.entity_id)})</option>`
|
|
||||||
),
|
|
||||||
].join("");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
options.innerHTML = "";
|
options.innerHTML = "";
|
||||||
select.innerHTML = `<option value="">Geräteliste konnte nicht geladen werden</option>`;
|
select.innerHTML = `<option value="">Geräteliste konnte nicht geladen werden</option>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadContextOptions() {
|
function actuatorGroupLabel(domain) {
|
||||||
|
const labels = {
|
||||||
|
light: "Lichter",
|
||||||
|
switch: "Schalter / Helper",
|
||||||
|
cover: "Rollläden / Cover",
|
||||||
|
fan: "Lüftung / Ventilatoren",
|
||||||
|
humidifier: "Befeuchter / Entfeuchter",
|
||||||
|
};
|
||||||
|
return labels[domain] || domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActuatorSelect() {
|
||||||
|
const select = document.getElementById("actuator-select");
|
||||||
|
if (!select) return;
|
||||||
|
const domain = document.getElementById("actuator-domain-filter")?.value || "";
|
||||||
|
const query = normalizedSearch(document.getElementById("actuator-search")?.value || "");
|
||||||
|
const filtered = actuatorChoices
|
||||||
|
.filter(entity => !domain || entity.domain === domain)
|
||||||
|
.filter(entity => matchesSearch(entity, query))
|
||||||
|
.slice(0, 120);
|
||||||
|
const domains = [...new Set(filtered.map(entity => entity.domain))].sort();
|
||||||
|
select.innerHTML = [
|
||||||
|
`<option value="">${filtered.length ? "Gerät auswählen ..." : "Keine passenden Geräte gefunden"}</option>`,
|
||||||
|
...domains.map(group => `
|
||||||
|
<optgroup label="${escapeHtml(actuatorGroupLabel(group))}">
|
||||||
|
${filtered
|
||||||
|
.filter(entity => entity.domain === group)
|
||||||
|
.map(entity => `<option value="${escapeHtml(entity.entity_id)}">${escapeHtml(entityLabel(entity))}</option>`)
|
||||||
|
.join("")}
|
||||||
|
</optgroup>
|
||||||
|
`),
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadContextOptions(actuatorId) {
|
||||||
try {
|
try {
|
||||||
contextOptions = await api("v1/actuators/context-options");
|
contextOptions = await api(`v1/actuators/context-options?actuator_entity_id=${encodeURIComponent(actuatorId)}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
contextOptions = [];
|
contextOptions = [];
|
||||||
}
|
}
|
||||||
@@ -258,6 +364,31 @@ function selectActuatorFromList() {
|
|||||||
if (value) document.getElementById("actuator-input").value = value;
|
if (value) document.getElementById("actuator-input").value = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderManualContextSelect() {
|
||||||
|
const select = document.getElementById("manual-context-select");
|
||||||
|
if (!select) return;
|
||||||
|
const category = document.getElementById("manual-context-category")?.value || "";
|
||||||
|
const query = normalizedSearch(document.getElementById("manual-context-filter")?.value || "");
|
||||||
|
const selectedNow = new Set([
|
||||||
|
...manualContextState.selected,
|
||||||
|
...Array.from(select.selectedOptions).map(option => option.value),
|
||||||
|
]);
|
||||||
|
const filtered = manualContextState.options
|
||||||
|
.filter(entity => !category || categoryForEntity(entity) === category)
|
||||||
|
.filter(entity => matchesSearch(entity, query))
|
||||||
|
.slice(0, 80);
|
||||||
|
select.innerHTML = filtered.length
|
||||||
|
? optionGroups(filtered, selectedNow)
|
||||||
|
: `<option value="">Keine passenden Vorschläge</option>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEntityIds(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.split(/[\s,;]+/)
|
||||||
|
.map(item => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
async function configureActuator() {
|
async function configureActuator() {
|
||||||
const actuatorId = (
|
const actuatorId = (
|
||||||
document.getElementById("actuator-input").value.trim()
|
document.getElementById("actuator-input").value.trim()
|
||||||
@@ -328,6 +459,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
|
record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
|
||||||
}
|
}
|
||||||
|
await loadContextOptions(actuatorId);
|
||||||
const contexts = [
|
const contexts = [
|
||||||
record.assignment.selected_numeric_entity_id,
|
record.assignment.selected_numeric_entity_id,
|
||||||
...record.assignment.selected_context_entity_ids,
|
...record.assignment.selected_context_entity_ids,
|
||||||
@@ -343,32 +475,50 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|||||||
const relatedAutomations = record.behavior.related_automations || [];
|
const relatedAutomations = record.behavior.related_automations || [];
|
||||||
const manualContextIds = new Set(record.assignment.selected_context_entity_ids || []);
|
const manualContextIds = new Set(record.assignment.selected_context_entity_ids || []);
|
||||||
const numericOptions = contextOptions.filter(entity => entity.domain === "sensor");
|
const numericOptions = contextOptions.filter(entity => entity.domain === "sensor");
|
||||||
|
const suggestedIds = new Set(contextOptions.map(entity => entity.entity_id));
|
||||||
|
const manualOnlyIds = [
|
||||||
|
record.assignment.selected_numeric_entity_id,
|
||||||
|
...manualContextIds,
|
||||||
|
].filter(entityId => entityId && !suggestedIds.has(entityId));
|
||||||
|
const contextCategories = [...new Set(contextOptions
|
||||||
|
.filter(entity => entity.entity_id !== record.actuator_entity_id)
|
||||||
|
.map(categoryForEntity))]
|
||||||
|
.sort();
|
||||||
|
manualContextState = {
|
||||||
|
options: contextOptions.filter(entity => entity.entity_id !== record.actuator_entity_id),
|
||||||
|
selected: manualContextIds,
|
||||||
|
};
|
||||||
const manualAssignment = `
|
const manualAssignment = `
|
||||||
<div class="manual-context">
|
<div class="manual-context">
|
||||||
<h3>Kontext selbst festlegen</h3>
|
<h3>Kontext selbst festlegen</h3>
|
||||||
<p class="muted">Hier kannst du Sensoren und Zustände ergänzen, die deiner Meinung nach wichtig für den Schaltvorgang sind. Beispiele: PIR, Helligkeit außen, Luftfeuchtigkeit innen/außen oder ein Lichtzustand.</p>
|
<p class="muted">Die Vorschläge sind aktorbezogen vorsortiert. Wenn etwas fehlt, trage die Entity-ID unten manuell ein, z. B. PIR, Helligkeit außen, Luftfeuchtigkeit oder Lichtzustände.</p>
|
||||||
<label for="manual-numeric-select">Optionaler Haupt-Messsensor</label>
|
<label for="manual-numeric-select">Optionaler Haupt-Messsensor</label>
|
||||||
<select id="manual-numeric-select">
|
<select id="manual-numeric-select">
|
||||||
<option value="">Keinen numerischen Hauptsensor verwenden</option>
|
<option value="">Keinen numerischen Hauptsensor verwenden</option>
|
||||||
${numericOptions.map(entity => `
|
${optionGroups(numericOptions, new Set([record.assignment.selected_numeric_entity_id].filter(Boolean)))}
|
||||||
<option value="${escapeHtml(entity.entity_id)}" ${record.assignment.selected_numeric_entity_id === entity.entity_id ? "selected" : ""}>
|
|
||||||
${escapeHtml(entity.area_name || "Ohne Bereich")} - ${escapeHtml(entity.friendly_name || entity.entity_id)} (${escapeHtml(entity.entity_id)})
|
|
||||||
</option>
|
|
||||||
`).join("")}
|
|
||||||
</select>
|
</select>
|
||||||
<label for="manual-context-select">Zusätzliche Kontext-Entities</label>
|
<div class="inline-controls">
|
||||||
|
<div>
|
||||||
|
<label for="manual-context-category">Kategorie</label>
|
||||||
|
<select id="manual-context-category" onchange="renderManualContextSelect()">
|
||||||
|
<option value="">Alle relevanten Vorschläge</option>
|
||||||
|
${contextCategories.map(category => `<option value="${escapeHtml(category)}">${escapeHtml(category)}</option>`).join("")}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="manual-context-filter">Vorschläge durchsuchen</label>
|
||||||
|
<input id="manual-context-filter" placeholder="z. B. treppe, bewegung, lux" oninput="renderManualContextSelect()" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label for="manual-context-select">Zusätzliche Kontext-Entities aus Vorschlägen</label>
|
||||||
<select id="manual-context-select" multiple>
|
<select id="manual-context-select" multiple>
|
||||||
${contextOptions
|
${optionGroups(manualContextState.options.slice(0, 80), manualContextIds)}
|
||||||
.filter(entity => entity.entity_id !== record.actuator_entity_id)
|
|
||||||
.map(entity => `
|
|
||||||
<option value="${escapeHtml(entity.entity_id)}" ${manualContextIds.has(entity.entity_id) ? "selected" : ""}>
|
|
||||||
${escapeHtml(entity.area_name || "Ohne Bereich")} - ${escapeHtml(entity.friendly_name || entity.entity_id)} (${escapeHtml(entity.entity_id)})
|
|
||||||
</option>
|
|
||||||
`).join("")}
|
|
||||||
</select>
|
</select>
|
||||||
|
<label for="manual-context-freeform">Entity-IDs manuell ergänzen</label>
|
||||||
|
<textarea id="manual-context-freeform" class="manual-entry" placeholder="Eine oder mehrere Entity-IDs, getrennt durch Komma, Leerzeichen oder neue Zeilen">${escapeHtml(manualOnlyIds.join("\n"))}</textarea>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button onclick="saveManualAssignment('${escapeHtml(record.actuator_entity_id)}')">Diese Kontext-Auswahl speichern</button>
|
<button onclick="saveManualAssignment('${escapeHtml(record.actuator_entity_id)}')">Diese Kontext-Auswahl speichern</button>
|
||||||
<button class="secondary" onclick="loadContextOptions().then(() => showActuator('${escapeHtml(record.actuator_entity_id)}'))">Listen neu laden</button>
|
<button class="secondary" onclick="loadContextOptions('${escapeHtml(record.actuator_entity_id)}').then(() => showActuator('${escapeHtml(record.actuator_entity_id)}'))">Vorschläge neu laden</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -439,9 +589,14 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
|||||||
|
|
||||||
async function saveManualAssignment(actuatorId) {
|
async function saveManualAssignment(actuatorId) {
|
||||||
const numericEntityId = document.getElementById("manual-numeric-select").value || null;
|
const numericEntityId = document.getElementById("manual-numeric-select").value || null;
|
||||||
const contextEntityIds = Array.from(
|
const selectedContextIds = Array.from(
|
||||||
document.getElementById("manual-context-select").selectedOptions,
|
document.getElementById("manual-context-select").selectedOptions,
|
||||||
).map(option => option.value);
|
).map(option => option.value).filter(value => value.includes("."));
|
||||||
|
const freeformContextIds = parseEntityIds(
|
||||||
|
document.getElementById("manual-context-freeform").value,
|
||||||
|
);
|
||||||
|
const contextEntityIds = [...new Set([...selectedContextIds, ...freeformContextIds])]
|
||||||
|
.filter(entityId => entityId !== numericEntityId);
|
||||||
try {
|
try {
|
||||||
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/assignment`, {
|
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/assignment`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "sillyhome-next"
|
name = "sillyhome-next"
|
||||||
version = "0.7.3"
|
version = "0.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 = [
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ def _service(
|
|||||||
model_store=str(tmp_path / "models"),
|
model_store=str(tmp_path / "models"),
|
||||||
automation_store=str(tmp_path / "automations"),
|
automation_store=str(tmp_path / "automations"),
|
||||||
actuator_store=str(tmp_path / "actuators"),
|
actuator_store=str(tmp_path / "actuators"),
|
||||||
history_days=14,
|
history_days=31,
|
||||||
min_training_points=5,
|
min_training_points=5,
|
||||||
retrain_stale_hours=24,
|
retrain_stale_hours=24,
|
||||||
reconcile_interval_seconds=900,
|
reconcile_interval_seconds=900,
|
||||||
|
|||||||
@@ -115,6 +115,14 @@ def _install_service(tmp_path: Path) -> None:
|
|||||||
friendly_name="Abstellkammer Bewegung",
|
friendly_name="Abstellkammer Bewegung",
|
||||||
area_name="Abstellkammer",
|
area_name="Abstellkammer",
|
||||||
),
|
),
|
||||||
|
HaEntitySummary(
|
||||||
|
entity_id="sensor.pfsense_interface_vpn_inbytes",
|
||||||
|
domain="sensor",
|
||||||
|
device_class="data_size",
|
||||||
|
state_class="measurement",
|
||||||
|
unit_of_measurement="KiB",
|
||||||
|
friendly_name="pfSense Interface VPN inbytes",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
ha_url="http://ha.local",
|
ha_url="http://ha.local",
|
||||||
@@ -208,10 +216,14 @@ def test_context_options_returns_learnable_entities(tmp_path: Path) -> None:
|
|||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
_install_service(tmp_path)
|
_install_service(tmp_path)
|
||||||
|
|
||||||
response = client.get("/v1/actuators/context-options")
|
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
||||||
|
response = client.get(
|
||||||
|
"/v1/actuators/context-options",
|
||||||
|
params={"actuator_entity_id": "light.abstellkammer"},
|
||||||
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
entity_ids = {item["entity_id"] for item in response.json()}
|
entity_ids = {item["entity_id"] for item in response.json()}
|
||||||
assert "sensor.abstellkammer_illuminance" in entity_ids
|
assert "sensor.abstellkammer_illuminance" in entity_ids
|
||||||
assert "binary_sensor.abstellkammer_motion" in entity_ids
|
assert "binary_sensor.abstellkammer_motion" in entity_ids
|
||||||
assert "light.abstellkammer" in entity_ids
|
assert "sensor.pfsense_interface_vpn_inbytes" not in entity_ids
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ def test_dashboard_is_served_at_root() -> None:
|
|||||||
assert "Gerät zum Lernen auswählen" in response.text
|
assert "Gerät zum Lernen auswählen" in response.text
|
||||||
assert "Entitätsname oder Gerät aus Home Assistant" in response.text
|
assert "Entitätsname oder Gerät aus Home Assistant" in response.text
|
||||||
assert "Oder aus Liste wählen" in response.text
|
assert "Oder aus Liste wählen" in response.text
|
||||||
|
assert "Liste durchsuchen" in response.text
|
||||||
assert "Wie gewohnt bedienen" in response.text
|
assert "Wie gewohnt bedienen" in response.text
|
||||||
assert "Ohne deine spätere Freigabe wird nichts geschaltet" in response.text
|
assert "Ohne deine spätere Freigabe wird nichts geschaltet" in response.text
|
||||||
assert "Du wählst keine Sensoren und erstellst keine Regeln" in response.text
|
assert "Du wählst keine Sensoren und erstellst keine Regeln" in response.text
|
||||||
@@ -23,6 +24,8 @@ def test_dashboard_is_served_at_root() -> None:
|
|||||||
assert "Davon erkannte HA-Automationen" in response.text
|
assert "Davon erkannte HA-Automationen" in response.text
|
||||||
assert "Aktuelle Situation auswerten" in response.text
|
assert "Aktuelle Situation auswerten" in response.text
|
||||||
assert "Kontext selbst festlegen" in response.text
|
assert "Kontext selbst festlegen" in response.text
|
||||||
|
assert "Entity-IDs manuell ergänzen" in response.text
|
||||||
|
assert "manual-context-freeform" in response.text
|
||||||
assert "Diese Kontext-Auswahl speichern" in response.text
|
assert "Diese Kontext-Auswahl speichern" in response.text
|
||||||
assert "manual-context-select" in response.text
|
assert "manual-context-select" in response.text
|
||||||
assert "Die Prüfung simuliert keinen Sensorwechsel" in response.text
|
assert "Die Prüfung simuliert keinen Sensorwechsel" in response.text
|
||||||
|
|||||||
@@ -55,18 +55,22 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
|
|||||||
'{"type":"auth_ok"}',
|
'{"type":"auth_ok"}',
|
||||||
(
|
(
|
||||||
'{"type":"event","event":{"event_type":"state_changed",'
|
'{"type":"event","event":{"event_type":"state_changed",'
|
||||||
'"entity_id":"light.test","new_state":{"state":"on"}}}'
|
'"data":{"entity_id":"light.test","new_state":{"state":"on"}}}}'
|
||||||
),
|
),
|
||||||
asyncio.CancelledError(),
|
asyncio.CancelledError(),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("websockets.connect", return_value=fake_ws):
|
with patch("websockets.connect", return_value=fake_ws) as connect:
|
||||||
try:
|
try:
|
||||||
await _ha_event_listener(mock_app, mock_client)
|
await _ha_event_listener(mock_app, mock_client)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
connect.assert_called_once_with(
|
||||||
|
"ws://homeassistant:8123/api/websocket",
|
||||||
|
ping_interval=None,
|
||||||
|
)
|
||||||
assert fake_ws.sent == [
|
assert fake_ws.sent == [
|
||||||
{"type": "auth", "access_token": "test-token"},
|
{"type": "auth", "access_token": "test-token"},
|
||||||
{"id": 1, "type": "subscribe_events", "event_type": "state_changed"},
|
{"id": 1, "type": "subscribe_events", "event_type": "state_changed"},
|
||||||
|
|||||||
Reference in New Issue
Block a user