feat: add manual context assignment and fix actuator discovery
This commit is contained in:
11
CHANGELOG.md
11
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,11 +262,15 @@ class ActuatorReconciliationService:
|
|||||||
),
|
),
|
||||||
context=True,
|
context=True,
|
||||||
)
|
)
|
||||||
assignment = self._select_assignment(
|
assignment = (
|
||||||
|
self._manual_assignment(record.manual_override)
|
||||||
|
if record.manual_override is not None
|
||||||
|
else self._select_assignment(
|
||||||
actuator=actuator,
|
actuator=actuator,
|
||||||
numeric_candidates=numeric_candidates,
|
numeric_candidates=numeric_candidates,
|
||||||
context_candidates=context_candidates,
|
context_candidates=context_candidates,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
lifecycle = self._reconcile_lifecycle(
|
lifecycle = self._reconcile_lifecycle(
|
||||||
actuator=actuator,
|
actuator=actuator,
|
||||||
assignment=assignment,
|
assignment=assignment,
|
||||||
@@ -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"})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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('"', """)
|
.replaceAll('"', """)
|
||||||
.replaceAll("'", "'");
|
.replaceAll("'", "'");
|
||||||
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(
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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=[],
|
context_entity_ids=["sensor.abstellkammer_illuminance"],
|
||||||
note="Alte manuelle Zuordnung",
|
note="Manuell wichtiger Sensor",
|
||||||
)
|
)
|
||||||
}
|
|
||||||
)
|
|
||||||
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user