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

@@ -13,6 +13,7 @@ from app.actuators.models import (
AssignmentSource,
LifecycleAuditEntry,
LifecycleStatus,
ManualOverride,
ModelLifecycleState,
ReconciliationState,
model_id_for_actuator,
@@ -47,6 +48,7 @@ _STOPWORDS = frozenset(
"light",
"licht",
"lichtschalter",
"monitoring",
"power",
"sensor",
"state",
@@ -55,6 +57,7 @@ _STOPWORDS = frozenset(
"value",
}
)
_GENERIC_AREA_NAMES = frozenset({"monitoring", "system", "technik"})
_NUMERIC_AUTO_ACCEPT_SCORE = 0.82
_NUMERIC_AUTO_ACCEPT_MIN_SCORE = 0.5
_NUMERIC_MIN_MARGIN = 0.18
@@ -93,6 +96,67 @@ class ActuatorReconciliationService:
self._registry.archive(model_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:
state = self._store.load_reconciliation_state().model_copy(
update={
@@ -198,10 +262,14 @@ class ActuatorReconciliationService:
),
context=True,
)
assignment = self._select_assignment(
actuator=actuator,
numeric_candidates=numeric_candidates,
context_candidates=context_candidates,
assignment = (
self._manual_assignment(record.manual_override)
if record.manual_override is not None
else self._select_assignment(
actuator=actuator,
numeric_candidates=numeric_candidates,
context_candidates=context_candidates,
)
)
lifecycle = self._reconcile_lifecycle(
actuator=actuator,
@@ -212,7 +280,7 @@ class ActuatorReconciliationService:
updated = record.model_copy(
update={
"assignment": assignment,
"manual_override": None,
"manual_override": record.manual_override,
"numeric_candidates": numeric_candidates,
"context_candidates": context_candidates,
"lifecycle": lifecycle,
@@ -228,6 +296,23 @@ class ActuatorReconciliationService:
)
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(
self,
*,
@@ -522,7 +607,12 @@ def _score_candidate(
if overlap:
score += min(0.4, 0.1 * len(overlap))
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
evidence.append(f"Gleicher Bereich: {actuator.area_name}")
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
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]:
if context:
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
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):
@@ -32,6 +45,12 @@ class AutomationControlRequest(BaseModel):
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])
def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaEntitySummary]:
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]
@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])
def list_configured(request: Request) -> list[ActuatorRecord]:
return _service(request).list_configured()
@@ -116,6 +151,27 @@ def set_activation(
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(
"/{actuator_entity_id}/related-automations/refresh",
response_model=ActuatorRecord,

View File

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

View File

@@ -100,7 +100,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app = FastAPI(
title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="0.7.2",
version="0.7.3",
lifespan=lifespan,
)
app.state.settings = load_settings()

View File

@@ -26,6 +26,7 @@
.bad { color: #ff8f8f; }
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[multiple] { min-height:190px; }
button { min-height:44px; margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; }
button.secondary { background: #37495c; }
button.danger { background: #7b3434; }
@@ -49,6 +50,7 @@
.actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; }
.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; }
.manual-context { margin-top:14px; background:#111a23; border:1px solid #31404d; border-radius:14px; padding:14px; }
code { color:#cfe0ec; overflow-wrap:anywhere; }
@media (max-width: 760px) {
header { padding:18px 14px; }
@@ -121,6 +123,10 @@
<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">
<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>
<p id="actuator-config-result" class="muted">Noch kein Aktor ausgewählt.</p>
</section>
@@ -145,6 +151,8 @@ const escapeHtml = value => String(value ?? "")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
let currentActuatorId = null;
let actuatorChoices = [];
let contextOptions = [];
async function api(path, 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>`;
chips.innerHTML = "";
}
await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators()]);
await Promise.all([loadActuatorDiscovery(), loadContextOptions(), loadConfiguredActuators()]);
}
async function loadActuatorDiscovery() {
const options = document.getElementById("actuator-options");
const select = document.getElementById("actuator-select");
try {
const [available, configured] = await Promise.all([
api("v1/actuators/discovery"),
api("v1/actuators"),
]);
const configuredIds = new Set(configured.map(record => record.actuator_entity_id));
const choices = available.filter(entity => !configuredIds.has(entity.entity_id));
options.innerHTML = choices.map(entity =>
actuatorChoices = available.filter(entity => !configuredIds.has(entity.entity_id));
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>`
).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) {
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() {
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");
if (!actuatorId) return;
result.textContent = "Kontext wird automatisch analysiert ...";
@@ -309,6 +341,37 @@ async function showActuator(actuatorId, evaluationMessage = "") {
pattern => pattern.source === "automation",
).length;
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"
? `<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>`
@@ -366,6 +429,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
${automationControls}
<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>"}
${manualAssignment}
`;
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
} 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) {
try {
const record = await api(