feat: add manual context assignment and fix actuator discovery

This commit is contained in:
2026-06-14 23:15:00 +02:00
parent d87d3abc00
commit 09e14689a3
11 changed files with 380 additions and 39 deletions

View File

@@ -1,5 +1,16 @@
# Changelog # Changelog
## 0.7.3 - 2026-06-14
- Automatische Kontextzuordnung ignoriert generische Bereiche wie `Monitoring`,
damit System-/Disk-/Überhitzungssensoren nicht fälschlich Lichtaktoren erklären
- Aktor-Auswahl auf tatsächlich sicher steuerbare Domains begrenzt:
`light`, `switch`, `cover`, `fan`, `humidifier`
- Neue manuelle Kontext-Zuordnung pro Aktor: Haupt-Messsensor optional setzen und
mehrere relevante Kontext-Entities wie PIR, Außenhelligkeit, Luftfeuchtigkeit
oder andere Lichtzustände auswählen
- Dashboard-Dropdown durch echtes Select plus Suche ersetzt; mobile Bedienung und
Aktor-Details enthalten Speichern/Neu-laden-Aktionen für manuelle Kontextwahl
## 0.7.2 - 2026-06-14 ## 0.7.2 - 2026-06-14
- Home-Assistant-Entity-Metadaten werden in Batches gelesen, damit große HA- - Home-Assistant-Entity-Metadaten werden in Batches gelesen, damit große HA-
Installationen nicht mehr am Template-Ausgabe-Limit scheitern Installationen nicht mehr am Template-Ausgabe-Limit scheitern

View File

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

View File

@@ -13,6 +13,7 @@ from app.actuators.models import (
AssignmentSource, AssignmentSource,
LifecycleAuditEntry, LifecycleAuditEntry,
LifecycleStatus, LifecycleStatus,
ManualOverride,
ModelLifecycleState, ModelLifecycleState,
ReconciliationState, ReconciliationState,
model_id_for_actuator, model_id_for_actuator,
@@ -47,6 +48,7 @@ _STOPWORDS = frozenset(
"light", "light",
"licht", "licht",
"lichtschalter", "lichtschalter",
"monitoring",
"power", "power",
"sensor", "sensor",
"state", "state",
@@ -55,6 +57,7 @@ _STOPWORDS = frozenset(
"value", "value",
} }
) )
_GENERIC_AREA_NAMES = frozenset({"monitoring", "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
@@ -93,6 +96,67 @@ class ActuatorReconciliationService:
self._registry.archive(model_id) self._registry.archive(model_id)
self._store.delete(actuator_entity_id) self._store.delete(actuator_entity_id)
def set_manual_assignment(
self,
actuator_entity_id: str,
*,
numeric_entity_id: str | None,
context_entity_ids: list[str],
note: str | None = None,
) -> ActuatorRecord:
now = datetime.now(timezone.utc)
record = self._store.get(actuator_entity_id)
entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()}
actuator = entities.get(actuator_entity_id)
if actuator is None:
raise KeyError("Aktuator-Konfiguration nicht gefunden.")
selected_context_ids = list(dict.fromkeys(context_entity_ids))
selected_ids = [
entity_id
for entity_id in [numeric_entity_id, *selected_context_ids]
if entity_id
]
missing = [entity_id for entity_id in selected_ids if entity_id not in entities]
if missing:
raise ValueError(f"Unbekannte Home-Assistant-Entity: {', '.join(missing)}")
if actuator_entity_id in selected_ids:
raise ValueError("Der Aktor selbst kann nicht als Kontextsensor verwendet werden.")
override = ManualOverride(
numeric_entity_id=numeric_entity_id,
context_entity_ids=selected_context_ids,
updated_at=now,
note=note,
)
assignment = self._manual_assignment(override)
lifecycle = self._reconcile_lifecycle(
actuator=actuator,
assignment=assignment,
lifecycle=record.lifecycle.model_copy(update={"last_reconciled_at": now}),
now=now,
)
updated = record.model_copy(
update={
"assignment": assignment,
"manual_override": override,
"numeric_candidates": _merge_manual_candidates(
record.numeric_candidates,
entities,
[numeric_entity_id] if numeric_entity_id else [],
role=EntityRole.MEASUREMENT,
),
"context_candidates": _merge_manual_candidates(
record.context_candidates,
entities,
selected_context_ids,
role=EntityRole.CONTEXT,
),
"lifecycle": lifecycle,
"updated_at": now,
}
)
return self._store.upsert(updated)
def reconcile_all(self, trigger: str = "manual") -> ReconciliationState: def reconcile_all(self, trigger: str = "manual") -> ReconciliationState:
state = self._store.load_reconciliation_state().model_copy( state = self._store.load_reconciliation_state().model_copy(
update={ update={
@@ -198,10 +262,14 @@ class ActuatorReconciliationService:
), ),
context=True, context=True,
) )
assignment = self._select_assignment( assignment = (
actuator=actuator, self._manual_assignment(record.manual_override)
numeric_candidates=numeric_candidates, if record.manual_override is not None
context_candidates=context_candidates, else self._select_assignment(
actuator=actuator,
numeric_candidates=numeric_candidates,
context_candidates=context_candidates,
)
) )
lifecycle = self._reconcile_lifecycle( lifecycle = self._reconcile_lifecycle(
actuator=actuator, actuator=actuator,
@@ -212,7 +280,7 @@ class ActuatorReconciliationService:
updated = record.model_copy( updated = record.model_copy(
update={ update={
"assignment": assignment, "assignment": assignment,
"manual_override": None, "manual_override": record.manual_override,
"numeric_candidates": numeric_candidates, "numeric_candidates": numeric_candidates,
"context_candidates": context_candidates, "context_candidates": context_candidates,
"lifecycle": lifecycle, "lifecycle": lifecycle,
@@ -228,6 +296,23 @@ class ActuatorReconciliationService:
) )
return updated return updated
@staticmethod
def _manual_assignment(override: ManualOverride) -> AssignmentSelection:
selected_context_ids = list(dict.fromkeys(override.context_entity_ids))
selected_count = len(selected_context_ids) + (1 if override.numeric_entity_id else 0)
return AssignmentSelection(
selected_numeric_entity_id=override.numeric_entity_id,
selected_context_entity_ids=selected_context_ids,
source=AssignmentSource.MANUAL,
confidence=1.0 if selected_count else 0.0,
review_required=selected_count == 0,
reason=(
f"Manuell festgelegt: {selected_count} Kontext-Entity(s) werden verwendet."
if selected_count
else "Manuelle Zuordnung enthält noch keine Kontext-Entities."
),
)
def _select_assignment( def _select_assignment(
self, self,
*, *,
@@ -522,7 +607,12 @@ def _score_candidate(
if overlap: if overlap:
score += min(0.4, 0.1 * len(overlap)) score += min(0.4, 0.1 * len(overlap))
evidence.append(f"Gemeinsame Tokens: {', '.join(overlap[:4])}") evidence.append(f"Gemeinsame Tokens: {', '.join(overlap[:4])}")
if actuator.area_name and entity.area_name and actuator.area_name == entity.area_name: 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
):
score += 0.35 score += 0.35
evidence.append(f"Gleicher Bereich: {actuator.area_name}") evidence.append(f"Gleicher Bereich: {actuator.area_name}")
if actuator.device_id and entity.device_id and actuator.device_id == entity.device_id: if actuator.device_id and entity.device_id and actuator.device_id == entity.device_id:
@@ -550,6 +640,49 @@ def _score_candidate(
return round(min(score, 1.0), 4), evidence return round(min(score, 1.0), 4), evidence
def _merge_manual_candidates(
candidates: list[AssignmentCandidate],
entities: dict[str, HaEntitySummary],
selected_entity_ids: list[str],
*,
role: EntityRole,
) -> list[AssignmentCandidate]:
by_id = {candidate.entity_id: candidate for candidate in candidates}
for entity_id in selected_entity_ids:
existing = by_id.get(entity_id)
if existing is not None:
by_id[entity_id] = existing.model_copy(
update={
"auto_accepted": True,
"confidence": 1.0,
"evidence": [
*existing.evidence,
"Manuell vom Nutzer als relevant festgelegt.",
],
}
)
continue
entity = entities.get(entity_id)
if entity is None:
continue
by_id[entity_id] = AssignmentCandidate(
entity_id=entity.entity_id,
domain=entity.domain,
role=role,
device_class=entity.device_class,
state_class=entity.state_class,
unit_of_measurement=entity.unit_of_measurement,
friendly_name=entity.friendly_name,
area_name=entity.area_name,
device_name=entity.device_name,
score=1.0,
confidence=1.0,
auto_accepted=True,
evidence=["Manuell vom Nutzer als relevant festgelegt."],
)
return sorted(by_id.values(), key=lambda item: (-item.confidence, item.entity_id))
def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]: def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]:
if context: if context:
return frozenset({"door", "garage_door", "motion", "occupancy", "opening", "presence"}) return frozenset({"door", "garage_door", "motion", "occupancy", "opening", "presence"})

View File

@@ -14,6 +14,19 @@ 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):
@@ -32,6 +45,12 @@ class AutomationControlRequest(BaseModel):
enabled: bool enabled: bool
class ManualAssignmentRequest(BaseModel):
numeric_entity_id: str | None = Field(default=None, pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$")
context_entity_ids: list[str] = Field(default_factory=list)
note: str | None = Field(default=None, max_length=500)
@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()}
@@ -42,6 +61,22 @@ 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("/context-options", response_model=list[HaEntitySummary])
def context_options(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaEntitySummary]:
return sorted(
[
entity
for entity in ha_reader.read_entities()
if entity.domain in _MANUAL_CONTEXT_DOMAINS
],
key=lambda entity: (
entity.area_name or "",
entity.friendly_name or entity.entity_id,
entity.entity_id,
),
)
@router.get("", response_model=list[ActuatorRecord]) @router.get("", response_model=list[ActuatorRecord])
def list_configured(request: Request) -> list[ActuatorRecord]: def list_configured(request: Request) -> list[ActuatorRecord]:
return _service(request).list_configured() return _service(request).list_configured()
@@ -116,6 +151,27 @@ def set_activation(
raise HTTPException(status_code=409, detail=str(exc)) from exc raise HTTPException(status_code=409, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/assignment", response_model=ActuatorRecord)
def set_manual_assignment(
actuator_entity_id: str,
payload: ManualAssignmentRequest,
request: Request,
) -> ActuatorRecord:
try:
record = _service(request).set_manual_assignment(
actuator_entity_id,
numeric_entity_id=payload.numeric_entity_id,
context_entity_ids=payload.context_entity_ids,
note=payload.note,
)
_behavior(request).train(record.actuator_entity_id)
return _behavior(request).evaluate(record.actuator_entity_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@router.post( @router.post(
"/{actuator_entity_id}/related-automations/refresh", "/{actuator_entity_id}/related-automations/refresh",
response_model=ActuatorRecord, response_model=ActuatorRecord,

View File

@@ -82,18 +82,11 @@ _BINARY_CONTEXT_CLASSES = frozenset({
"window", "window",
}) })
_ACTUATOR_DOMAINS = frozenset({ _ACTUATOR_DOMAINS = frozenset({
"button",
"climate",
"cover", "cover",
"fan", "fan",
"humidifier", "humidifier",
"light", "light",
"lock",
"scene",
"select",
"siren",
"switch", "switch",
"valve",
}) })
_CONTEXT_DOMAINS = frozenset({"device_tracker", "person", "sun", "weather", "zone"}) _CONTEXT_DOMAINS = frozenset({"device_tracker", "person", "sun", "weather", "zone"})
_LEARNABLE_CONTEXT_DOMAINS = frozenset({"device_tracker", "person", "weather"}) _LEARNABLE_CONTEXT_DOMAINS = frozenset({"device_tracker", "person", "weather"})

View File

@@ -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.2", version="0.7.3",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()

View File

@@ -26,6 +26,7 @@
.bad { color: #ff8f8f; } .bad { color: #ff8f8f; }
label { display: block; margin: 9px 0 4px; color: #b9c9d6; } label { display: block; margin: 9px 0 4px; color: #b9c9d6; }
select,input,button { box-sizing: border-box; width: 100%; border-radius: 10px; border: 1px solid #3b4b5b; padding: 12px; background: #101820; color: #fff; font:inherit; } select,input,button { box-sizing: border-box; width: 100%; border-radius: 10px; border: 1px solid #3b4b5b; padding: 12px; background: #101820; color: #fff; font:inherit; }
select[multiple] { min-height:190px; }
button { min-height:44px; margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; } button { min-height:44px; margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; }
button.secondary { background: #37495c; } button.secondary { background: #37495c; }
button.danger { background: #7b3434; } button.danger { background: #7b3434; }
@@ -49,6 +50,7 @@
.actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; } .actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; }
.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; }
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; }
@@ -121,6 +123,10 @@
<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>
<label for="actuator-select">Oder aus Liste wählen</label>
<select id="actuator-select" onchange="selectActuatorFromList()">
<option value="">Geräteliste wird geladen ...</option>
</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>
</section> </section>
@@ -145,6 +151,8 @@ const escapeHtml = value => String(value ?? "")
.replaceAll('"', "&quot;") .replaceAll('"', "&quot;")
.replaceAll("'", "&#039;"); .replaceAll("'", "&#039;");
let currentActuatorId = null; let currentActuatorId = null;
let actuatorChoices = [];
let contextOptions = [];
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});
@@ -209,28 +217,52 @@ async function loadOverview() {
status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`; status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
chips.innerHTML = ""; chips.innerHTML = "";
} }
await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators()]); await Promise.all([loadActuatorDiscovery(), loadContextOptions(), 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");
try { try {
const [available, configured] = await Promise.all([ const [available, configured] = await Promise.all([
api("v1/actuators/discovery"), api("v1/actuators/discovery"),
api("v1/actuators"), api("v1/actuators"),
]); ]);
const configuredIds = new Set(configured.map(record => record.actuator_entity_id)); const configuredIds = new Set(configured.map(record => record.actuator_entity_id));
const choices = available.filter(entity => !configuredIds.has(entity.entity_id)); actuatorChoices = available.filter(entity => !configuredIds.has(entity.entity_id));
options.innerHTML = choices.map(entity => options.innerHTML = actuatorChoices.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 = [
`<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>`;
} }
} }
async function loadContextOptions() {
try {
contextOptions = await api("v1/actuators/context-options");
} catch (error) {
contextOptions = [];
}
}
function selectActuatorFromList() {
const value = document.getElementById("actuator-select").value;
if (value) document.getElementById("actuator-input").value = value;
}
async function configureActuator() { async function configureActuator() {
const actuatorId = document.getElementById("actuator-input").value.trim(); const actuatorId = (
document.getElementById("actuator-input").value.trim()
|| document.getElementById("actuator-select").value.trim()
);
const result = document.getElementById("actuator-config-result"); const result = document.getElementById("actuator-config-result");
if (!actuatorId) return; if (!actuatorId) return;
result.textContent = "Kontext wird automatisch analysiert ..."; result.textContent = "Kontext wird automatisch analysiert ...";
@@ -309,6 +341,37 @@ async function showActuator(actuatorId, evaluationMessage = "") {
pattern => pattern.source === "automation", pattern => pattern.source === "automation",
).length; ).length;
const relatedAutomations = record.behavior.related_automations || []; const relatedAutomations = record.behavior.related_automations || [];
const manualContextIds = new Set(record.assignment.selected_context_entity_ids || []);
const numericOptions = contextOptions.filter(entity => entity.domain === "sensor");
const manualAssignment = `
<div class="manual-context">
<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>
<label for="manual-numeric-select">Optionaler Haupt-Messsensor</label>
<select id="manual-numeric-select">
<option value="">Keinen numerischen Hauptsensor verwenden</option>
${numericOptions.map(entity => `
<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>
<label for="manual-context-select">Zusätzliche Kontext-Entities</label>
<select id="manual-context-select" multiple>
${contextOptions
.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>
<div class="actions">
<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>
</div>
</div>
`;
const activationButton = record.behavior.mode === "active" const activationButton = record.behavior.mode === "active"
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">SillyHome stoppen und pausierte HA-Automationen fortsetzen</button> ? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">SillyHome stoppen und pausierte HA-Automationen fortsetzen</button>
<button class="secondary" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, false)">SillyHome stoppen; HA-Automationen pausiert lassen</button>` <button class="secondary" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, false)">SillyHome stoppen; HA-Automationen pausiert lassen</button>`
@@ -366,6 +429,7 @@ 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>"}
${manualAssignment}
`; `;
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"}); document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
} catch (error) { } catch (error) {
@@ -373,6 +437,27 @@ async function showActuator(actuatorId, evaluationMessage = "") {
} }
} }
async function saveManualAssignment(actuatorId) {
const numericEntityId = document.getElementById("manual-numeric-select").value || null;
const contextEntityIds = Array.from(
document.getElementById("manual-context-select").selectedOptions,
).map(option => option.value);
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/assignment`, {
method: "POST",
body: JSON.stringify({
numeric_entity_id: numericEntityId,
context_entity_ids: contextEntityIds,
note: "Manuell im Dashboard gesetzt",
}),
});
await loadConfiguredActuators();
await showActuator(actuatorId, "Manuelle Kontext-Auswahl gespeichert.");
} catch (error) {
alert(error.message);
}
}
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.2" version = "0.7.3"
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

@@ -7,7 +7,6 @@ from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import ( from app.actuators.models import (
AssignmentSource, AssignmentSource,
LifecycleStatus, LifecycleStatus,
ManualOverride,
model_id_for_actuator, model_id_for_actuator,
) )
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
@@ -240,7 +239,45 @@ def test_reconciliation_does_not_cross_assign_other_room_light_energy(
assert record.lifecycle.status is LifecycleStatus.ARCHIVED assert record.lifecycle.status is LifecycleStatus.ARCHIVED
def test_legacy_manual_override_is_cleared_and_automatic_mapping_wins(tmp_path: Path) -> None: def test_reconciliation_ignores_generic_monitoring_area_for_automatic_context(
tmp_path: Path,
) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [
HaEntitySummary(
entity_id="light.abstellkammer",
domain="light",
friendly_name="Licht Abstellkammer",
area_name="Monitoring",
),
HaEntitySummary(
entity_id="binary_sensor.disk_overheating",
domain="binary_sensor",
device_class="problem",
friendly_name="Max. fehlerhafte Sektoren ueberschritten",
area_name="Monitoring",
),
HaEntitySummary(
entity_id="sensor.router_power",
domain="sensor",
device_class="power",
state_class="measurement",
unit_of_measurement="W",
friendly_name="Router Leistung",
area_name="Monitoring",
),
]
service = _service(tmp_path, entities, {"sensor.router_power": _points(8, start, 1.0)})
record = service.configure_actuator("light.abstellkammer")
assert record.assignment.selected_numeric_entity_id is None
assert record.assignment.selected_context_entity_ids == []
assert record.assignment.review_required is True
assert record.lifecycle.status is LifecycleStatus.ARCHIVED
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 = [
HaEntitySummary( HaEntitySummary(
@@ -273,21 +310,18 @@ def test_legacy_manual_override_is_cleared_and_automatic_mapping_wins(tmp_path:
"sensor.abstellkammer_power": _points(8, start, 30.0), "sensor.abstellkammer_power": _points(8, start, 30.0),
} }
service = _service(tmp_path, entities, history) service = _service(tmp_path, entities, history)
configured = service.configure_actuator("light.abstellkammer") service.configure_actuator("light.abstellkammer")
legacy = configured.model_copy( service.set_manual_assignment(
update={ "light.abstellkammer",
"manual_override": ManualOverride( numeric_entity_id="sensor.abstellkammer_power",
numeric_entity_id="sensor.abstellkammer_power", context_entity_ids=["sensor.abstellkammer_illuminance"],
context_entity_ids=[], note="Manuell wichtiger Sensor",
note="Alte manuelle Zuordnung",
)
}
) )
service._store.upsert(legacy)
restarted = _service(tmp_path, entities, history) restarted = _service(tmp_path, entities, history)
record = restarted.reconcile_actuator("light.abstellkammer") record = restarted.reconcile_actuator("light.abstellkammer")
assert record.assignment.selected_numeric_entity_id == "sensor.abstellkammer_illuminance" assert record.assignment.selected_numeric_entity_id == "sensor.abstellkammer_power"
assert record.assignment.source.value == "automatic" assert record.assignment.selected_context_entity_ids == ["sensor.abstellkammer_illuminance"]
assert record.manual_override is None assert record.assignment.source is AssignmentSource.MANUAL
assert record.manual_override is not None

View File

@@ -179,14 +179,39 @@ def test_actuator_api_configures_reconciles_and_removes(tmp_path: Path) -> None:
assert client.get("/v1/actuators").json() == [] assert client.get("/v1/actuators").json() == []
def test_manual_override_endpoint_is_not_exposed(tmp_path: Path) -> None: def test_manual_assignment_endpoint_updates_context(tmp_path: Path) -> None:
with TestClient(app) as client: with TestClient(app) as client:
_install_service(tmp_path) _install_service(tmp_path)
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"}) client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
response = client.post( response = client.post(
"/v1/actuators/light.abstellkammer/override", "/v1/actuators/light.abstellkammer/assignment",
json={"numeric_entity_id": "sensor.abstellkammer_illuminance"}, json={
"numeric_entity_id": "sensor.abstellkammer_illuminance",
"context_entity_ids": ["binary_sensor.abstellkammer_motion"],
"note": "Manuell gesetzt",
},
) )
assert response.status_code == 404 assert response.status_code == 200
payload = response.json()
assert payload["assignment"]["source"] == "manual"
assert payload["assignment"]["selected_numeric_entity_id"] == (
"sensor.abstellkammer_illuminance"
)
assert payload["assignment"]["selected_context_entity_ids"] == [
"binary_sensor.abstellkammer_motion"
]
def test_context_options_returns_learnable_entities(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
response = client.get("/v1/actuators/context-options")
assert response.status_code == 200
entity_ids = {item["entity_id"] for item in response.json()}
assert "sensor.abstellkammer_illuminance" in entity_ids
assert "binary_sensor.abstellkammer_motion" in entity_ids
assert "light.abstellkammer" in entity_ids

View File

@@ -12,6 +12,7 @@ def test_dashboard_is_served_at_root() -> None:
assert "So gehst du vor" in response.text assert "So gehst du vor" in response.text
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 "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
@@ -21,6 +22,9 @@ def test_dashboard_is_served_at_root() -> None:
assert "Pausieren" in response.text assert "Pausieren" in response.text
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 "Diese Kontext-Auswahl speichern" 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
assert "Kein frischer passender Sensorwechsel erkannt" in response.text assert "Kein frischer passender Sensorwechsel erkannt" in response.text
assert "Vorhersage jetzt prüfen" not in response.text assert "Vorhersage jetzt prüfen" not in response.text