Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc8bec6aa9 | |||
| 058c5dd015 | |||
| 9ddb065f62 | |||
| 8222f24ebe | |||
| a7a2f8c78a | |||
| faf4099756 |
22
CHANGELOG.md
22
CHANGELOG.md
@@ -1,5 +1,27 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
||||||
|
- Onboarding-Vorschläge laden im Dashboard nachgelagert, damit Status,
|
||||||
|
Aktor-Auswahl und bestehende Geräte nicht auf Automation-Discovery warten.
|
||||||
|
|
||||||
## 0.7.13 - 2026-06-16
|
## 0.7.13 - 2026-06-16
|
||||||
- Diagnose-/Schutzsensoren wie Überhitzung und Überlast werden nicht mehr nur
|
- Diagnose-/Schutzsensoren wie Überhitzung und Überlast werden nicht mehr nur
|
||||||
wegen gleicher Strom-/Monitoring-Bereiche automatisch als Lichtkontext
|
wegen gleicher Strom-/Monitoring-Bereiche automatisch als Lichtkontext
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name: SillyHome Next
|
name: SillyHome Next
|
||||||
version: "0.7.13"
|
version: "0.7.17"
|
||||||
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
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ _CONTEXT_DOMAINS = frozenset({
|
|||||||
"input_datetime",
|
"input_datetime",
|
||||||
"input_number",
|
"input_number",
|
||||||
"input_select",
|
"input_select",
|
||||||
|
"input_text",
|
||||||
"person",
|
"person",
|
||||||
"sun",
|
"sun",
|
||||||
"weather",
|
"weather",
|
||||||
@@ -122,6 +123,29 @@ _NUMERIC_STATE_CLASSES = frozenset({"measurement", "total", "total_increasing"})
|
|||||||
|
|
||||||
|
|
||||||
def classify_entity(entity: HaEntitySummary) -> DiscoveredEntity:
|
def classify_entity(entity: HaEntitySummary) -> DiscoveredEntity:
|
||||||
|
if entity.domain in _ACTUATOR_DOMAINS:
|
||||||
|
return _result(
|
||||||
|
entity,
|
||||||
|
EntityRole.ACTUATOR,
|
||||||
|
category=_actuator_category(entity),
|
||||||
|
learnable=False,
|
||||||
|
reason="Aktor ist ein mögliches Automationsziel, aber kein Trainingssensor.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if entity.domain in _CONTEXT_DOMAINS:
|
||||||
|
learnable = entity.domain in _LEARNABLE_CONTEXT_DOMAINS
|
||||||
|
return _result(
|
||||||
|
entity,
|
||||||
|
EntityRole.CONTEXT,
|
||||||
|
category=_context_category(entity),
|
||||||
|
learnable=learnable,
|
||||||
|
reason=(
|
||||||
|
"Kontextquelle für Training und Erklärungen."
|
||||||
|
if learnable
|
||||||
|
else "Kontextquelle ohne direkte Trainingsfreigabe."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
if entity.domain == "sensor" and (
|
if entity.domain == "sensor" and (
|
||||||
entity.state_class in _NUMERIC_STATE_CLASSES
|
entity.state_class in _NUMERIC_STATE_CLASSES
|
||||||
or entity.device_class in _MEASUREMENT_CLASSES
|
or entity.device_class in _MEASUREMENT_CLASSES
|
||||||
@@ -144,29 +168,6 @@ def classify_entity(entity: HaEntitySummary) -> DiscoveredEntity:
|
|||||||
reason="Binärer Kontextsensor für Zustands- und Anwesenheitsmuster.",
|
reason="Binärer Kontextsensor für Zustands- und Anwesenheitsmuster.",
|
||||||
)
|
)
|
||||||
|
|
||||||
if entity.domain in _CONTEXT_DOMAINS:
|
|
||||||
learnable = entity.domain in _LEARNABLE_CONTEXT_DOMAINS
|
|
||||||
return _result(
|
|
||||||
entity,
|
|
||||||
EntityRole.CONTEXT,
|
|
||||||
category=_context_category(entity),
|
|
||||||
learnable=learnable,
|
|
||||||
reason=(
|
|
||||||
"Kontextquelle für Training und Erklärungen."
|
|
||||||
if learnable
|
|
||||||
else "Kontextquelle ohne direkte Trainingsfreigabe."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if entity.domain in _ACTUATOR_DOMAINS:
|
|
||||||
return _result(
|
|
||||||
entity,
|
|
||||||
EntityRole.ACTUATOR,
|
|
||||||
category=_actuator_category(entity),
|
|
||||||
learnable=False,
|
|
||||||
reason="Aktor ist ein mögliches Automationsziel, aber kein Trainingssensor.",
|
|
||||||
)
|
|
||||||
|
|
||||||
return _result(
|
return _result(
|
||||||
entity,
|
entity,
|
||||||
EntityRole.UNSUPPORTED,
|
EntityRole.UNSUPPORTED,
|
||||||
@@ -226,26 +227,33 @@ def _actuator_category(entity: HaEntitySummary) -> str:
|
|||||||
if entity.domain == "lock":
|
if entity.domain == "lock":
|
||||||
return "lock"
|
return "lock"
|
||||||
if entity.domain == "fan":
|
if entity.domain == "fan":
|
||||||
return "fan"
|
return "ventilation"
|
||||||
|
if entity.domain == "humidifier":
|
||||||
|
return "climate"
|
||||||
if entity.domain in {"media_player", "remote"}:
|
if entity.domain in {"media_player", "remote"}:
|
||||||
return "media_tv"
|
return "media_tv"
|
||||||
if entity.domain in {"input_boolean", "number"}:
|
if entity.domain == "input_boolean" or entity.domain == "number" or entity.domain == "input_button":
|
||||||
return "helper"
|
return "helper"
|
||||||
|
if entity.domain == "sensor" and (entity.device_class or entity.unit_of_measurement):
|
||||||
|
return "measurement"
|
||||||
return entity.domain
|
return entity.domain
|
||||||
|
|
||||||
|
|
||||||
def _measurement_category(entity: HaEntitySummary) -> str:
|
def _measurement_category(entity: HaEntitySummary) -> str:
|
||||||
device_class = entity.device_class or ""
|
device_class = entity.device_class or ""
|
||||||
if device_class == "illuminance":
|
unit = (entity.unit_of_measurement or "").lower()
|
||||||
|
if device_class == "illuminance" or unit == "lx":
|
||||||
return "brightness"
|
return "brightness"
|
||||||
if device_class == "temperature":
|
if device_class == "temperature" or unit in {"°c", "°f", "k"}:
|
||||||
return "temperature"
|
return "temperature"
|
||||||
if device_class in {"humidity", "moisture"}:
|
if device_class in {"humidity", "moisture"} or unit in {"%", "rh", "g/m³", "kg/m³"}:
|
||||||
return "humidity"
|
return "humidity"
|
||||||
if device_class in {"power", "energy", "current", "voltage"}:
|
if device_class in {"power", "energy", "current", "voltage"} or unit in {"w", "kw", "kwh", "a", "v", "va", "var"}:
|
||||||
return "energy_power"
|
return "energy_power"
|
||||||
if device_class in {"battery", "signal_strength"}:
|
if device_class in {"battery", "signal_strength"} or unit in {"%", "dbm"}:
|
||||||
return "diagnostic"
|
return "diagnostic"
|
||||||
|
if unit:
|
||||||
|
return f"measurement:{unit}"
|
||||||
return "measurement"
|
return "measurement"
|
||||||
|
|
||||||
|
|
||||||
@@ -253,7 +261,7 @@ def _binary_category(entity: HaEntitySummary) -> str:
|
|||||||
device_class = entity.device_class or ""
|
device_class = entity.device_class or ""
|
||||||
if device_class in {"motion", "occupancy", "presence"}:
|
if device_class in {"motion", "occupancy", "presence"}:
|
||||||
return "presence_motion"
|
return "presence_motion"
|
||||||
if device_class in {"door", "garage_door", "opening", "window"}:
|
if device_class in {"door", "garage_door", "opening", "window", "button"}:
|
||||||
return "opening"
|
return "opening"
|
||||||
if device_class in {"smoke", "safety", "problem"}:
|
if device_class in {"smoke", "safety", "problem"}:
|
||||||
return "safety"
|
return "safety"
|
||||||
@@ -265,4 +273,8 @@ def _context_category(entity: HaEntitySummary) -> str:
|
|||||||
return "helper"
|
return "helper"
|
||||||
if entity.domain in {"person", "device_tracker", "zone"}:
|
if entity.domain in {"person", "device_tracker", "zone"}:
|
||||||
return "presence_location"
|
return "presence_location"
|
||||||
|
if entity.domain == "sun":
|
||||||
|
return "weather"
|
||||||
|
if entity.domain == "weather":
|
||||||
|
return "weather"
|
||||||
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.13",
|
version="0.7.17",
|
||||||
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],
|
||||||
|
|||||||
@@ -285,14 +285,15 @@ function optionGroups(entities, selectedIds = new Set()) {
|
|||||||
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");
|
||||||
|
let reconciliation = null;
|
||||||
try {
|
try {
|
||||||
const [health, websocket, ml, reconciliation, actuators] = await Promise.all([
|
const [health, websocket, ml] = await Promise.all([
|
||||||
api("health"),
|
api("health"),
|
||||||
api("health/websocket"),
|
api("health/websocket"),
|
||||||
api("ml/health"),
|
api("ml/health"),
|
||||||
api("v1/actuators/reconciliation/state"),
|
|
||||||
api("v1/actuators"),
|
|
||||||
]);
|
]);
|
||||||
|
reconciliation = await api("v1/actuators/reconciliation/state");
|
||||||
|
const actuators = await api("v1/actuators");
|
||||||
status.innerHTML = `<p class="ok">System bereit</p><p>Letzte automatische Prüfung: ${escapeHtml(reconciliation.last_completed_at || "noch nie")}</p>`;
|
status.innerHTML = `<p class="ok">System bereit</p><p>Letzte automatische Prüfung: ${escapeHtml(reconciliation.last_completed_at || "noch nie")}</p>`;
|
||||||
chips.innerHTML = [
|
chips.innerHTML = [
|
||||||
`<span class="chip">API: ${escapeHtml(health.status)}</span>`,
|
`<span class="chip">API: ${escapeHtml(health.status)}</span>`,
|
||||||
@@ -305,23 +306,16 @@ 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([
|
await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators()]);
|
||||||
loadActuatorDiscovery(),
|
void loadActuatorSuggestions();
|
||||||
loadActuatorSuggestions(),
|
|
||||||
loadConfiguredActuators(),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadActuatorDiscovery() {
|
async function loadActuatorDiscovery() {
|
||||||
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 = await api("v1/actuators/discovery");
|
||||||
api("v1/actuators/discovery"),
|
actuatorChoices = available;
|
||||||
api("v1/actuators"),
|
|
||||||
]);
|
|
||||||
const configuredIds = new Set(configured.map(record => record.actuator_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 =>
|
||||||
`<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("");
|
||||||
@@ -360,20 +354,17 @@ async function loadActuatorSuggestions() {
|
|||||||
|
|
||||||
function actuatorGroupLabel(domain) {
|
function actuatorGroupLabel(domain) {
|
||||||
const labels = {
|
const labels = {
|
||||||
button: "Buttons",
|
|
||||||
climate: "Heizungen / Klima",
|
|
||||||
light: "Lichter",
|
light: "Lichter",
|
||||||
input_boolean: "Helper-Schalter",
|
switch_socket: "Steckdosen / Schalter",
|
||||||
input_button: "Helper-Buttons",
|
button: "Buttons",
|
||||||
|
cover_shutter: "Rollläden / Fenster",
|
||||||
|
heating: "Heizungen / Klima",
|
||||||
|
ventilation: "Lüftung / Ventilatoren",
|
||||||
|
climate: "Klima",
|
||||||
|
helper: "Helper",
|
||||||
|
measurement: "Sensoren / Messung",
|
||||||
lock: "Schlösser",
|
lock: "Schlösser",
|
||||||
media_player: "TV / Medien",
|
media_tv: "TV / Medien",
|
||||||
number: "Numerische Helper",
|
|
||||||
remote: "Fernbedienungen",
|
|
||||||
switch: "Schalter / Steckdosen",
|
|
||||||
cover: "Rollläden / Cover",
|
|
||||||
fan: "Lüftung / Ventilatoren",
|
|
||||||
humidifier: "Befeuchter / Entfeuchter",
|
|
||||||
valve: "Ventile",
|
|
||||||
};
|
};
|
||||||
return labels[domain] || domain;
|
return labels[domain] || domain;
|
||||||
}
|
}
|
||||||
@@ -468,13 +459,28 @@ async function configureActuator() {
|
|||||||
async function loadConfiguredActuators() {
|
async function loadConfiguredActuators() {
|
||||||
const box = document.getElementById("configured-actuators");
|
const box = document.getElementById("configured-actuators");
|
||||||
try {
|
try {
|
||||||
const rows = await api("v1/actuators");
|
const [rows, entities] = await Promise.all([
|
||||||
|
api("v1/actuators"),
|
||||||
|
api("v1/entities"),
|
||||||
|
]);
|
||||||
|
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 === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -496,7 +502,8 @@ async function loadConfiguredActuators() {
|
|||||||
</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;
|
||||||
}
|
}
|
||||||
@@ -805,3 +812,31 @@ loadOverview();
|
|||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|
||||||
|
function categoryLabel(category) {
|
||||||
|
const labels = {
|
||||||
|
light: "Licht",
|
||||||
|
switch_socket: "Steckdosen / Schalter",
|
||||||
|
button: "Buttons",
|
||||||
|
cover_shutter: "Rollläden / Cover",
|
||||||
|
heating: "Heizungen / Klima",
|
||||||
|
fan: "Lüftung / Ventilatoren",
|
||||||
|
ventilation: "Lüftung / Ventilatoren",
|
||||||
|
media_tv: "TV / Medien",
|
||||||
|
helper: "Helper",
|
||||||
|
presence_motion: "Bewegung / Präsenz",
|
||||||
|
opening: "Fenster / Türen",
|
||||||
|
weather: "Wetter",
|
||||||
|
brightness: "Helligkeit",
|
||||||
|
temperature: "Temperatur",
|
||||||
|
humidity: "Feuchtigkeit",
|
||||||
|
energy_power: "Strom / Energie",
|
||||||
|
diagnostic: "Diagnose",
|
||||||
|
measurement: "Messung",
|
||||||
|
binary: "Binär",
|
||||||
|
presence_location: "Anwesenheit / Ort",
|
||||||
|
climate: "Klima",
|
||||||
|
};
|
||||||
|
return labels[category] || category;
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "sillyhome-next"
|
name = "sillyhome-next"
|
||||||
version = "0.7.13"
|
version = "0.7.17"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -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