Compare commits

...

3 Commits

Author SHA1 Message Date
a7a2f8c78a Make SillyHome startup resilient 2026-06-16 13:43:41 +02:00
faf4099756 Load actuator suggestions asynchronously 2026-06-16 12:14:51 +02:00
1b2b76455a Tighten context onboarding and actuator suggestions 2026-06-16 12:06:03 +02:00
12 changed files with 366 additions and 14 deletions

View File

@@ -1,5 +1,29 @@
# Changelog # Changelog
## 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
- Diagnose-/Schutzsensoren wie Überhitzung und Überlast werden nicht mehr nur
wegen gleicher Strom-/Monitoring-Bereiche automatisch als Lichtkontext
übernommen.
- Verwendete Kontext-Entities können pro Aktor direkt entfernt und damit als
manuelle Zuordnung überschrieben werden.
- Onboarding-Vorschläge zeigen passende, noch nicht eingerichtete Aktoren aus
bestehenden Automationen und naheliegenden Kontexten.
- TV-/Medien-Aktoren über `media_player` und Fernbedienungen über `remote`
werden in Discovery und Auswahl berücksichtigt.
## 0.7.12 - 2026-06-16 ## 0.7.12 - 2026-06-16
- Aktor-Auswahlliste zeigt maximal 50 Treffer gleichzeitig und fordert bei - Aktor-Auswahlliste zeigt maximal 50 Treffer gleichzeitig und fordert bei
größeren Mengen zum Eingrenzen per Suche oder Typfilter auf. größeren Mengen zum Eingrenzen per Suche oder Typfilter auf.

View File

@@ -1,5 +1,5 @@
name: SillyHome Next name: SillyHome Next
version: "0.7.12" version: "0.7.15"
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

View File

@@ -57,7 +57,7 @@ _STOPWORDS = frozenset(
"value", "value",
} }
) )
_GENERIC_AREA_NAMES = frozenset({"monitoring", "system", "technik"}) _GENERIC_AREA_NAMES = frozenset({"energie", "monitoring", "power", "strom", "system", "technik"})
_NUMERIC_AUTO_ACCEPT_SCORE = 0.82 _NUMERIC_AUTO_ACCEPT_SCORE = 0.82
_NUMERIC_AUTO_ACCEPT_MIN_SCORE = 0.5 _NUMERIC_AUTO_ACCEPT_MIN_SCORE = 0.5
_NUMERIC_MIN_MARGIN = 0.18 _NUMERIC_MIN_MARGIN = 0.18
@@ -88,6 +88,7 @@ _DIAGNOSTIC_TOKENS = frozenset({
"diagnostic", "diagnostic",
"firmware", "firmware",
"gesehen", "gesehen",
"heat",
"last", "last",
"linkquality", "linkquality",
"knoten", "knoten",
@@ -100,10 +101,28 @@ _DIAGNOSTIC_TOKENS = frozenset({
"signal", "signal",
"ssid", "ssid",
"status", "status",
"overheat",
"overheating",
"overload",
"uptime", "uptime",
"uberhitzung",
"ueberhitzung",
"ueberlast",
"überhitzung",
"überlast",
"wifi", "wifi",
"zuletzt", "zuletzt",
}) })
_AUTO_CONTEXT_CLASSES = frozenset({
"door",
"garage_door",
"illuminance",
"motion",
"occupancy",
"opening",
"presence",
"window",
})
class ActuatorReconciliationService: class ActuatorReconciliationService:
@@ -628,8 +647,12 @@ class ActuatorReconciliationService:
if context if context
else _NUMERIC_AUTO_ACCEPT_MIN_SCORE else _NUMERIC_AUTO_ACCEPT_MIN_SCORE
) )
can_auto_accept_context = (
not context or _eligible_for_auto_context(actuator, candidate)
)
auto_accepted = ( auto_accepted = (
candidate.score >= minimum_score can_auto_accept_context
and candidate.score >= minimum_score
and confidence >= auto_score and confidence >= auto_score
and (context or margin >= _NUMERIC_MIN_MARGIN) and (context or margin >= _NUMERIC_MIN_MARGIN)
) )
@@ -743,6 +766,23 @@ def _has_context_relationship(actuator: HaEntitySummary, entity: HaEntitySummary
) )
def _eligible_for_auto_context(
actuator: HaEntitySummary,
candidate: AssignmentCandidate,
) -> bool:
device_class = candidate.device_class or ""
if device_class in _AUTO_CONTEXT_CLASSES:
return True
if (
actuator.device_name
and candidate.device_name
and actuator.device_name == candidate.device_name
and candidate.domain in {"light", "switch"}
):
return True
return False
def _score_candidate( def _score_candidate(
actuator: HaEntitySummary, actuator: HaEntitySummary,
entity: HaEntitySummary, entity: HaEntitySummary,

View File

@@ -8,7 +8,7 @@ from app.actuators.models import ActuatorRecord, ReconciliationState
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine from app.behavior.engine import BehaviorEngine
from app.dependencies import get_ha_reader from app.dependencies import get_ha_reader
from app.ha.discovery import EntityRole from app.ha.discovery import DiscoveredEntity, EntityRole
from app.ha.exceptions import HaClientError from app.ha.exceptions import HaClientError
from app.ha.models import HaEntitySummary from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader from app.ha.reader import HaReader
@@ -43,6 +43,18 @@ class FeedbackRequest(BaseModel):
expected_state: str | None = Field(default=None, max_length=100) expected_state: str | None = Field(default=None, max_length=100)
class ActuatorSuggestion(BaseModel):
entity_id: str
domain: str
friendly_name: str | None = None
area_name: str | None = None
device_name: str | None = None
confidence: float
reason: str
related_automation_count: int = 0
likely_context_count: int = 0
@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()}
@@ -58,6 +70,66 @@ def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaE
return [entities[entity_id] for entity_id in actuator_ids if entity_id in entities] return [entities[entity_id] for entity_id in actuator_ids if entity_id in entities]
@router.get("/suggestions", response_model=list[ActuatorSuggestion])
def suggest_actuators(
request: Request,
ha_reader: HaReader = Depends(get_ha_reader),
) -> list[ActuatorSuggestion]:
entities = {entity.entity_id: entity for entity in ha_reader.read_entities()}
discovered = {entity.entity_id: entity for entity in ha_reader.discover()}
configured_ids = {record.actuator_entity_id for record in _service(request).list_configured()}
actuator_ids = _deduplicate_actuator_ids(
[
(entity.entity_id, entity.category)
for entity in discovered.values()
if entity.role is EntityRole.ACTUATOR
],
entities,
)
suggestions: list[ActuatorSuggestion] = []
for entity_id in actuator_ids:
if entity_id in configured_ids:
continue
entity = entities.get(entity_id)
if entity is None:
continue
try:
automations = ha_reader.find_automations_for_entity(entity_id)
except Exception:
automations = []
context_count = _likely_context_count(entity, entities, discovered)
if not automations and context_count == 0:
continue
confidence = 1.0 if automations else min(0.85, 0.35 + context_count * 0.1)
reason_parts = []
if automations:
reason_parts.append(f"{len(automations)} passende HA-Automation(en)")
if context_count:
reason_parts.append(f"{context_count} naheliegende Kontext-Entity(s)")
suggestions.append(
ActuatorSuggestion(
entity_id=entity.entity_id,
domain=entity.domain,
friendly_name=entity.friendly_name,
area_name=entity.area_name,
device_name=entity.device_name,
confidence=round(confidence, 4),
reason=", ".join(reason_parts),
related_automation_count=len(automations),
likely_context_count=context_count,
)
)
return sorted(
suggestions,
key=lambda item: (
-item.related_automation_count,
-item.confidence,
item.area_name or "",
item.friendly_name or item.entity_id,
),
)[:30]
@router.get("/context-options", response_model=list[HaEntitySummary]) @router.get("/context-options", response_model=list[HaEntitySummary])
def context_options( def context_options(
request: Request, request: Request,
@@ -294,3 +366,67 @@ def _actuator_duplicate_key(entity: HaEntitySummary, category: str) -> str:
if entity.device_name and category in {"light", "switch_socket", "button"}: if entity.device_name and category in {"light", "switch_socket", "button"}:
return f"device-name:{entity.device_name.lower()}:control" return f"device-name:{entity.device_name.lower()}:control"
return f"entity:{entity.entity_id}" return f"entity:{entity.entity_id}"
def _likely_context_count(
actuator: HaEntitySummary,
entities: dict[str, HaEntitySummary],
discovered: dict[str, DiscoveredEntity],
) -> int:
actuator_tokens = _tokens(actuator)
count = 0
for entity in entities.values():
if entity.entity_id == actuator.entity_id:
continue
descriptor = discovered.get(entity.entity_id)
role = descriptor.role if descriptor is not None else None
if role not in {
EntityRole.MEASUREMENT,
EntityRole.BINARY_CONTEXT,
EntityRole.CONTEXT,
}:
continue
if entity.device_class not in {
"door",
"energy",
"garage_door",
"humidity",
"illuminance",
"motion",
"occupancy",
"opening",
"power",
"presence",
"temperature",
"window",
}:
continue
same_area = bool(
actuator.area_name
and entity.area_name
and actuator.area_name == entity.area_name
)
same_device = bool(
actuator.device_id
and entity.device_id
and actuator.device_id == entity.device_id
)
token_match = bool(actuator_tokens.intersection(_tokens(entity)))
if same_area or same_device or token_match:
count += 1
return count
def _tokens(entity: HaEntitySummary) -> set[str]:
values = [
entity.entity_id,
entity.friendly_name,
entity.area_name,
entity.device_name,
]
tokens: set[str] = set()
for value in values:
if not value:
continue
tokens.update(token for token in value.lower().replace("_", " ").split() if len(token) > 2)
return tokens

View File

@@ -898,7 +898,7 @@ 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", "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 == "cover": if domain == "cover":
return {"open": "open_cover", "closed": "close_cover"}.get(target_state) return {"open": "open_cover", "closed": "close_cover"}.get(target_state)

View File

@@ -92,7 +92,9 @@ _ACTUATOR_DOMAINS = frozenset({
"input_button", "input_button",
"lock", "lock",
"light", "light",
"media_player",
"number", "number",
"remote",
"siren", "siren",
"switch", "switch",
"valve", "valve",
@@ -225,6 +227,8 @@ def _actuator_category(entity: HaEntitySummary) -> str:
return "lock" return "lock"
if entity.domain == "fan": if entity.domain == "fan":
return "fan" return "fan"
if entity.domain in {"media_player", "remote"}:
return "media_tv"
if entity.domain in {"input_boolean", "number"}: if entity.domain in {"input_boolean", "number"}:
return "helper" return "helper"
return entity.domain return entity.domain

View File

@@ -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.12", version="0.7.15",
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
try:
await asyncio.to_thread(service.reconcile_all, "scheduled") await asyncio.to_thread(service.reconcile_all, "scheduled")
engine = getattr(app.state, "behavior_engine", None) engine = getattr(app.state, "behavior_engine", None)
if isinstance(engine, BehaviorEngine): if isinstance(engine, BehaviorEngine):
await asyncio.to_thread(engine.train_all) await asyncio.to_thread(engine.train_all)
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)
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.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:
@@ -243,7 +275,11 @@ 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 (
websockets.exceptions.ConnectionClosed,
websockets.exceptions.InvalidStatus,
OSError,
) as exc:
logger.warning("WebSocket-Verbindung unterbrochen: %s. Wiederholung in 5s...", exc) logger.warning("WebSocket-Verbindung unterbrochen: %s. Wiederholung in 5s...", exc)
if ws_status is not None: if ws_status is not None:
ws_status.status = "reconnecting" ws_status.status = "reconnecting"
@@ -280,7 +316,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",
) )
try:
await asyncio.to_thread(engine.evaluate_all) 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]:

View File

@@ -142,6 +142,8 @@
<option value="lock">Schlösser</option> <option value="lock">Schlösser</option>
<option value="fan">Lüftung / Ventilatoren</option> <option value="fan">Lüftung / Ventilatoren</option>
<option value="humidifier">Befeuchter / Entfeuchter</option> <option value="humidifier">Befeuchter / Entfeuchter</option>
<option value="media_player">TV / Medien</option>
<option value="remote">Fernbedienungen</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>
@@ -157,6 +159,7 @@
</select> </select>
<button onclick="configureActuator()">Gerät hinzufügen und Beobachtung starten</button> <button onclick="configureActuator()">Gerät hinzufügen und Beobachtung starten</button>
<p id="actuator-config-result" class="muted">Noch kein Aktor ausgewählt.</p> <p id="actuator-config-result" class="muted">Noch kein Aktor ausgewählt.</p>
<div id="actuator-suggestions" class="card-list"></div>
</section> </section>
<section class="wide" id="observed"> <section class="wide" id="observed">
@@ -303,6 +306,7 @@ async function loadOverview() {
chips.innerHTML = ""; chips.innerHTML = "";
} }
await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators()]); await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators()]);
void loadActuatorSuggestions();
} }
async function loadActuatorDiscovery() { async function loadActuatorDiscovery() {
@@ -325,6 +329,32 @@ async function loadActuatorDiscovery() {
} }
} }
async function loadActuatorSuggestions() {
const box = document.getElementById("actuator-suggestions");
if (!box) return;
try {
const suggestions = await api("v1/actuators/suggestions");
box.innerHTML = suggestions.length ? `
<h3>Vorschläge aus bestehenden Zusammenhängen</h3>
${suggestions.slice(0, 8).map(item => `
<article class="actuator-card">
<div class="card-title">
<div>
<div class="entity-id">${escapeHtml(item.entity_id)}</div>
<div class="muted">${escapeHtml(item.area_name || item.device_name || item.domain)}</div>
</div>
<span class="chip">${Math.round(item.confidence * 100)} %</span>
</div>
<p class="muted">${escapeHtml(item.reason)}</p>
<button class="secondary" onclick="configureSuggestedActuator('${escapeHtml(item.entity_id)}')">Vorschlag übernehmen</button>
</article>
`).join("")}
` : "";
} catch (_) {
box.innerHTML = "";
}
}
function actuatorGroupLabel(domain) { function actuatorGroupLabel(domain) {
const labels = { const labels = {
button: "Buttons", button: "Buttons",
@@ -333,7 +363,9 @@ function actuatorGroupLabel(domain) {
input_boolean: "Helper-Schalter", input_boolean: "Helper-Schalter",
input_button: "Helper-Buttons", input_button: "Helper-Buttons",
lock: "Schlösser", lock: "Schlösser",
media_player: "TV / Medien",
number: "Numerische Helper", number: "Numerische Helper",
remote: "Fernbedienungen",
switch: "Schalter / Steckdosen", switch: "Schalter / Steckdosen",
cover: "Rollläden / Cover", cover: "Rollläden / Cover",
fan: "Lüftung / Ventilatoren", fan: "Lüftung / Ventilatoren",
@@ -486,6 +518,14 @@ async function showActuator(actuatorId, evaluationMessage = "") {
.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>: ${candidate.evidence.map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`)
.join(""); .join("");
const currentContextControls = contexts.length
? `<ul>${contexts.map(entityId => `
<li>
<code>${escapeHtml(entityId)}</code>
<button class="secondary compact" onclick="removeContextEntity('${escapeHtml(record.actuator_entity_id)}', '${escapeHtml(entityId)}')">Entfernen</button>
</li>
`).join("")}</ul>`
: "<p class='muted'>Noch keine Kontext-Entity ausgewählt.</p>";
const prediction = record.behavior.prediction; const prediction = record.behavior.prediction;
const learnedAutomationActions = record.behavior.patterns.filter( const learnedAutomationActions = record.behavior.patterns.filter(
pattern => pattern.source === "automation", pattern => pattern.source === "automation",
@@ -601,6 +641,8 @@ async function showActuator(actuatorId, evaluationMessage = "") {
${automationControls} ${automationControls}
<h3>Welche Zusammenhänge automatisch verwendet werden</h3> <h3>Welche Zusammenhänge automatisch verwendet werden</h3>
${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"} ${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"}
<h3>Verwendete Sensoren/Zustände ändern</h3>
${currentContextControls}
${manualAssignment} ${manualAssignment}
`; `;
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"}); document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
@@ -635,6 +677,34 @@ async function saveManualAssignment(actuatorId) {
} }
} }
async function removeContextEntity(actuatorId, entityId) {
try {
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
const numericEntityId = record.assignment.selected_numeric_entity_id === entityId
? null
: record.assignment.selected_numeric_entity_id;
const contextEntityIds = (record.assignment.selected_context_entity_ids || [])
.filter(id => id !== entityId);
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/assignment`, {
method: "POST",
body: JSON.stringify({
numeric_entity_id: numericEntityId,
context_entity_ids: contextEntityIds,
note: `Entity ${entityId} entfernt`,
}),
});
await loadConfiguredActuators();
await showActuator(actuatorId, "Kontext-Entity entfernt.");
} catch (error) {
alert(error.message);
}
}
async function configureSuggestedActuator(actuatorId) {
document.getElementById("actuator-input").value = actuatorId;
await configureActuator();
}
async function evaluateActuator(actuatorId) { async function evaluateActuator(actuatorId) {
try { try {
const record = await api( const record = await api(

View File

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

View File

@@ -277,6 +277,39 @@ def test_reconciliation_ignores_generic_monitoring_area_for_automatic_context(
assert record.lifecycle.status is LifecycleStatus.ARCHIVED assert record.lifecycle.status is LifecycleStatus.ARCHIVED
def test_reconciliation_does_not_auto_select_overload_sensors_by_power_area(
tmp_path: Path,
) -> None:
entities = [
HaEntitySummary(
entity_id="light.treppe_unten",
domain="light",
friendly_name="Licht Treppe Unten",
area_name="Strom",
),
HaEntitySummary(
entity_id="binary_sensor.shelly_schrank_channel_1_overload",
domain="binary_sensor",
device_class="problem",
friendly_name="Shelly Schrank Channel 1 Überlast",
area_name="Strom",
),
HaEntitySummary(
entity_id="binary_sensor.terrasse_terasse_overheating",
domain="binary_sensor",
device_class="problem",
friendly_name="Terrasse Terasse Überhitzung",
area_name="Strom",
),
]
service = _service(tmp_path, entities, {})
record = service.configure_actuator("light.treppe_unten")
assert record.assignment.selected_context_entity_ids == []
assert all(candidate.auto_accepted is False for candidate in record.context_candidates)
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 = [

View File

@@ -558,6 +558,7 @@ def test_cooldown_allows_opposite_follow_up_action(tmp_path: Path) -> None:
("domain", "state", "service"), ("domain", "state", "service"),
[ [
("light", "on", "turn_on"), ("light", "on", "turn_on"),
("media_player", "off", "turn_off"),
("switch", "off", "turn_off"), ("switch", "off", "turn_off"),
("cover", "open", "open_cover"), ("cover", "open", "open_cover"),
("cover", "closed", "close_cover"), ("cover", "closed", "close_cover"),

View File

@@ -91,6 +91,10 @@ def test_discovery_filters_domain_and_learnable() -> None:
HaEntitySummary(entity_id="input_boolean.sleep_mode", domain="input_boolean"), HaEntitySummary(entity_id="input_boolean.sleep_mode", domain="input_boolean"),
"helper", "helper",
), ),
(
HaEntitySummary(entity_id="media_player.tv", domain="media_player"),
"media_tv",
),
( (
HaEntitySummary( HaEntitySummary(
entity_id="sensor.brightness", entity_id="sensor.brightness",