Compare commits

...

2 Commits

Author SHA1 Message Date
18999ff68a Limit actuator picker results
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-16 11:43:00 +02:00
e2826e92ec Improve SillyHome discovery and feedback learning
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-16 11:38:32 +02:00
12 changed files with 526 additions and 13 deletions

View File

@@ -1,5 +1,18 @@
# Changelog # Changelog
## 0.7.12 - 2026-06-16
- Aktor-Auswahlliste zeigt maximal 50 Treffer gleichzeitig und fordert bei
größeren Mengen zum Eingrenzen per Suche oder Typfilter auf.
## 0.7.11 - 2026-06-16
- Aktor-Discovery erkennt weitere steuerbare HA-Domains wie Buttons, Helper,
Heizungen, Schlösser, Ventile und numerische Helper.
- Aktor-Auswahl dedupliziert Licht-/Schalter-Doppelungen pro Gerät und gruppiert
zusätzliche Typen im Dashboard.
- Discovery liefert Kategorien für Mess-, Binär-, Kontext- und Aktor-Entities.
- Nutzerfeedback kann Vorhersagen als korrekt oder falsch markieren und direkt
als Lernsignal speichern.
## 0.7.10 - 2026-06-16 ## 0.7.10 - 2026-06-16
- WebSocket-State-Changes aktualisieren einen internen Home-Assistant-State- - WebSocket-State-Changes aktualisieren einen internen Home-Assistant-State-
Cache und werten Aktoren direkt gegen diesen frischen Event-Zustand aus. Cache und werten Aktoren direkt gegen diesen frischen Event-Zustand aus.

View File

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

@@ -38,12 +38,22 @@ class ManualAssignmentRequest(BaseModel):
note: str | None = Field(default=None, max_length=500) note: str | None = Field(default=None, max_length=500)
class FeedbackRequest(BaseModel):
correct: bool
expected_state: str | None = Field(default=None, max_length=100)
@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()}
discovered = ha_reader.discover() discovered = ha_reader.discover()
actuator_ids = sorted( actuator_ids = _deduplicate_actuator_ids(
entity.entity_id for entity in discovered if entity.role is EntityRole.ACTUATOR [
(entity.entity_id, entity.category)
for entity in discovered
if entity.role is EntityRole.ACTUATOR
],
entities,
) )
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]
@@ -116,6 +126,22 @@ def evaluate_actuator(
raise HTTPException(status_code=404, detail=str(exc)) from exc raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/feedback", response_model=ActuatorRecord)
def record_feedback(
actuator_entity_id: str,
payload: FeedbackRequest,
request: Request,
) -> ActuatorRecord:
try:
return _behavior(request).record_feedback(
actuator_entity_id,
correct=payload.correct,
expected_state=payload.expected_state,
)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/{actuator_entity_id}/activation", response_model=ActuatorRecord) @router.post("/{actuator_entity_id}/activation", response_model=ActuatorRecord)
def set_activation( def set_activation(
actuator_entity_id: str, actuator_entity_id: str,
@@ -233,3 +259,38 @@ def _behavior(request: Request) -> BehaviorEngine:
detail="Verhaltenslernen ist nicht initialisiert.", detail="Verhaltenslernen ist nicht initialisiert.",
) )
return engine return engine
def _deduplicate_actuator_ids(
discovered: list[tuple[str, str]],
entities: dict[str, HaEntitySummary],
) -> list[str]:
priority = {
"light": 0,
"cover_shutter": 1,
"heating": 2,
"lock": 3,
"fan": 4,
"switch_socket": 5,
"button": 6,
"helper": 7,
}
selected: dict[str, tuple[int, str]] = {}
for entity_id, category in discovered:
entity = entities.get(entity_id)
if entity is None:
continue
key = _actuator_duplicate_key(entity, category)
rank = priority.get(category, 50)
current = selected.get(key)
if current is None or (rank, entity_id) < current:
selected[key] = (rank, entity_id)
return sorted(entity_id for _, entity_id in selected.values())
def _actuator_duplicate_key(entity: HaEntitySummary, category: str) -> str:
if entity.device_id and category in {"light", "switch_socket", "button"}:
return f"device:{entity.device_id}:control"
if entity.device_name and category in {"light", "switch_socket", "button"}:
return f"device-name:{entity.device_name.lower()}:control"
return f"entity:{entity.entity_id}"

View File

@@ -356,6 +356,95 @@ class BehaviorEngine:
) )
return self._save_behavior(record, behavior) return self._save_behavior(record, behavior)
def record_feedback(
self,
actuator_entity_id: str,
*,
correct: bool,
expected_state: str | None = None,
) -> ActuatorRecord:
record = self._store.get(actuator_entity_id)
now = datetime.now(timezone.utc)
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("Aktor ist aktuell nicht in Home Assistant verfügbar.")
context_ids = [
entity_id
for entity_id in [
record.assignment.selected_numeric_entity_id,
*record.assignment.selected_context_entity_ids,
]
if entity_id
]
current_context = {
entity_id: entities[entity_id].state
for entity_id in context_ids
if entity_id in entities and entities[entity_id].state is not None
}
prediction = record.behavior.prediction
patterns = list(record.behavior.patterns)
reason = "Nutzerfeedback gespeichert."
if correct and prediction is not None:
local = now.astimezone(ZoneInfo(self._settings.timezone))
patterns.append(
BehaviorPattern(
target_state=prediction.target_state,
minute_of_day=local.hour * 60 + local.minute,
weekday=local.weekday(),
context_states={
entity_id: state
for entity_id, state in current_context.items()
if state is not None
},
source="user_feedback",
weight=1.0,
observed_at=now,
)
)
reason = "Vorhersage wurde vom Nutzer als korrekt bestätigt."
else:
target = prediction.target_state if prediction is not None else None
if target:
patterns = [
pattern.model_copy(update={"weight": 0.1})
if pattern.target_state == target
and _pattern_context_matches(pattern, current_context)
else pattern
for pattern in patterns
]
if expected_state:
local = now.astimezone(ZoneInfo(self._settings.timezone))
patterns.append(
BehaviorPattern(
target_state=expected_state,
minute_of_day=local.hour * 60 + local.minute,
weekday=local.weekday(),
context_states={
entity_id: state
for entity_id, state in current_context.items()
if state is not None
},
source="user_correction",
weight=1.0,
observed_at=now,
)
)
reason = "Vorhersage wurde vom Nutzer als falsch markiert."
behavior = record.behavior.model_copy(
update={
"patterns": patterns[-_MAX_PATTERNS:],
"prediction": (
prediction.model_copy(update={"execution_reason": reason})
if prediction is not None
else None
),
"reason": reason,
"last_trained_at": now,
}
)
return self._save_behavior(record, behavior)
def refresh_related_automations(self, actuator_entity_id: str) -> ActuatorRecord: def refresh_related_automations(self, actuator_entity_id: str) -> ActuatorRecord:
record = self._store.get(actuator_entity_id) record = self._store.get(actuator_entity_id)
related = [ related = [
@@ -856,6 +945,20 @@ def _matches_own_execution(
) )
def _pattern_context_matches(
pattern: BehaviorPattern,
current_context: dict[str, str | None],
) -> bool:
comparable = [
(entity_id, expected)
for entity_id, expected in pattern.context_states.items()
if entity_id in current_context
]
if not comparable:
return False
return all(current_context[entity_id] == expected for entity_id, expected in comparable)
def _recent_context_transition( def _recent_context_transition(
history: dict[str, StateHistorySeries], history: dict[str, StateHistorySeries],
context_ids: list[str], context_ids: list[str],

View File

@@ -21,6 +21,7 @@ class DiscoveredEntity(BaseModel):
device_class: str | None = None device_class: str | None = None
state_class: str | None = None state_class: str | None = None
unit_of_measurement: str | None = None unit_of_measurement: str | None = None
category: str
role: EntityRole role: EntityRole
learnable: bool learnable: bool
reason: str reason: str
@@ -82,14 +83,39 @@ _BINARY_CONTEXT_CLASSES = frozenset({
"window", "window",
}) })
_ACTUATOR_DOMAINS = frozenset({ _ACTUATOR_DOMAINS = frozenset({
"button",
"climate",
"cover", "cover",
"fan", "fan",
"humidifier", "humidifier",
"input_boolean",
"input_button",
"lock",
"light", "light",
"number",
"siren",
"switch", "switch",
"valve",
})
_CONTEXT_DOMAINS = frozenset({
"device_tracker",
"input_boolean",
"input_datetime",
"input_number",
"input_select",
"person",
"sun",
"weather",
"zone",
})
_LEARNABLE_CONTEXT_DOMAINS = frozenset({
"device_tracker",
"input_boolean",
"input_number",
"input_select",
"person",
"weather",
}) })
_CONTEXT_DOMAINS = frozenset({"device_tracker", "person", "sun", "weather", "zone"})
_LEARNABLE_CONTEXT_DOMAINS = frozenset({"device_tracker", "person", "weather"})
_NUMERIC_STATE_CLASSES = frozenset({"measurement", "total", "total_increasing"}) _NUMERIC_STATE_CLASSES = frozenset({"measurement", "total", "total_increasing"})
@@ -102,6 +128,7 @@ def classify_entity(entity: HaEntitySummary) -> DiscoveredEntity:
return _result( return _result(
entity, entity,
EntityRole.MEASUREMENT, EntityRole.MEASUREMENT,
category=_measurement_category(entity),
learnable=True, learnable=True,
reason="Numerischer Messsensor für Zeitreihen und Training.", reason="Numerischer Messsensor für Zeitreihen und Training.",
) )
@@ -110,6 +137,7 @@ def classify_entity(entity: HaEntitySummary) -> DiscoveredEntity:
return _result( return _result(
entity, entity,
EntityRole.BINARY_CONTEXT, EntityRole.BINARY_CONTEXT,
category=_binary_category(entity),
learnable=True, learnable=True,
reason="Binärer Kontextsensor für Zustands- und Anwesenheitsmuster.", reason="Binärer Kontextsensor für Zustands- und Anwesenheitsmuster.",
) )
@@ -119,6 +147,7 @@ def classify_entity(entity: HaEntitySummary) -> DiscoveredEntity:
return _result( return _result(
entity, entity,
EntityRole.CONTEXT, EntityRole.CONTEXT,
category=_context_category(entity),
learnable=learnable, learnable=learnable,
reason=( reason=(
"Kontextquelle für Training und Erklärungen." "Kontextquelle für Training und Erklärungen."
@@ -131,6 +160,7 @@ def classify_entity(entity: HaEntitySummary) -> DiscoveredEntity:
return _result( return _result(
entity, entity,
EntityRole.ACTUATOR, EntityRole.ACTUATOR,
category=_actuator_category(entity),
learnable=False, learnable=False,
reason="Aktor ist ein mögliches Automationsziel, aber kein Trainingssensor.", reason="Aktor ist ein mögliches Automationsziel, aber kein Trainingssensor.",
) )
@@ -138,6 +168,7 @@ def classify_entity(entity: HaEntitySummary) -> DiscoveredEntity:
return _result( return _result(
entity, entity,
EntityRole.UNSUPPORTED, EntityRole.UNSUPPORTED,
category="unsupported",
learnable=False, learnable=False,
reason="Entity-Typ ist noch nicht für Lernen oder Automationen klassifiziert.", reason="Entity-Typ ist noch nicht für Lernen oder Automationen klassifiziert.",
) )
@@ -162,6 +193,7 @@ def _result(
entity: HaEntitySummary, entity: HaEntitySummary,
role: EntityRole, role: EntityRole,
*, *,
category: str,
learnable: bool, learnable: bool,
reason: str, reason: str,
) -> DiscoveredEntity: ) -> DiscoveredEntity:
@@ -171,7 +203,62 @@ def _result(
device_class=entity.device_class, device_class=entity.device_class,
state_class=entity.state_class, state_class=entity.state_class,
unit_of_measurement=entity.unit_of_measurement, unit_of_measurement=entity.unit_of_measurement,
category=category,
role=role, role=role,
learnable=learnable, learnable=learnable,
reason=reason, reason=reason,
) )
def _actuator_category(entity: HaEntitySummary) -> str:
if entity.domain == "light":
return "light"
if entity.domain == "switch":
return "switch_socket"
if entity.domain == "button" or entity.domain == "input_button":
return "button"
if entity.domain == "cover":
return "cover_shutter"
if entity.domain == "climate":
return "heating"
if entity.domain == "lock":
return "lock"
if entity.domain == "fan":
return "fan"
if entity.domain in {"input_boolean", "number"}:
return "helper"
return entity.domain
def _measurement_category(entity: HaEntitySummary) -> str:
device_class = entity.device_class or ""
if device_class == "illuminance":
return "brightness"
if device_class == "temperature":
return "temperature"
if device_class in {"humidity", "moisture"}:
return "humidity"
if device_class in {"power", "energy", "current", "voltage"}:
return "energy_power"
if device_class in {"battery", "signal_strength"}:
return "diagnostic"
return "measurement"
def _binary_category(entity: HaEntitySummary) -> str:
device_class = entity.device_class or ""
if device_class in {"motion", "occupancy", "presence"}:
return "presence_motion"
if device_class in {"door", "garage_door", "opening", "window"}:
return "opening"
if device_class in {"smoke", "safety", "problem"}:
return "safety"
return "binary"
def _context_category(entity: HaEntitySummary) -> str:
if entity.domain.startswith("input_"):
return "helper"
if entity.domain in {"person", "device_tracker", "zone"}:
return "presence_location"
return entity.domain

View File

@@ -102,7 +102,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.10", version="0.7.12",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()

View File

@@ -134,9 +134,16 @@
<option value="">Alle steuerbaren Typen</option> <option value="">Alle steuerbaren Typen</option>
<option value="light">Lichter</option> <option value="light">Lichter</option>
<option value="switch">Schalter / Helper</option> <option value="switch">Schalter / Helper</option>
<option value="button">Buttons</option>
<option value="input_button">Helper-Buttons</option>
<option value="input_boolean">Helper-Schalter</option>
<option value="cover">Rollläden / Cover</option> <option value="cover">Rollläden / Cover</option>
<option value="climate">Heizungen / Klima</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="number">Numerische Helper</option>
<option value="valve">Ventile</option>
</select> </select>
</div> </div>
<div> <div>
@@ -175,6 +182,7 @@ let currentActuatorId = null;
let actuatorChoices = []; let actuatorChoices = [];
let contextOptions = []; let contextOptions = [];
let manualContextState = {options: [], selected: new Set()}; let manualContextState = {options: [], selected: new Set()};
const ACTUATOR_RESULT_LIMIT = 50;
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});
@@ -319,11 +327,18 @@ async function loadActuatorDiscovery() {
function actuatorGroupLabel(domain) { function actuatorGroupLabel(domain) {
const labels = { const labels = {
button: "Buttons",
climate: "Heizungen / Klima",
light: "Lichter", light: "Lichter",
switch: "Schalter / Helper", input_boolean: "Helper-Schalter",
input_button: "Helper-Buttons",
lock: "Schlösser",
number: "Numerische Helper",
switch: "Schalter / Steckdosen",
cover: "Rollläden / Cover", cover: "Rollläden / Cover",
fan: "Lüftung / Ventilatoren", fan: "Lüftung / Ventilatoren",
humidifier: "Befeuchter / Entfeuchter", humidifier: "Befeuchter / Entfeuchter",
valve: "Ventile",
}; };
return labels[domain] || domain; return labels[domain] || domain;
} }
@@ -335,14 +350,17 @@ function renderActuatorSelect() {
const query = normalizedSearch(document.getElementById("actuator-search")?.value || ""); const query = normalizedSearch(document.getElementById("actuator-search")?.value || "");
const filtered = actuatorChoices const filtered = actuatorChoices
.filter(entity => !domain || entity.domain === domain) .filter(entity => !domain || entity.domain === domain)
.filter(entity => matchesSearch(entity, query)) .filter(entity => matchesSearch(entity, query));
.slice(0, 120); const visible = filtered.slice(0, ACTUATOR_RESULT_LIMIT);
const domains = [...new Set(filtered.map(entity => entity.domain))].sort(); const domains = [...new Set(visible.map(entity => entity.domain))].sort();
const limitLabel = filtered.length > visible.length
? ` - ${visible.length} von ${filtered.length}; Suche oder Typ weiter eingrenzen`
: "";
select.innerHTML = [ select.innerHTML = [
`<option value="">${filtered.length ? "Gerät auswählen ..." : "Keine passenden Geräte gefunden"}</option>`, `<option value="">${filtered.length ? `Gerät auswählen${limitLabel}` : "Keine passenden Geräte gefunden"}</option>`,
...domains.map(group => ` ...domains.map(group => `
<optgroup label="${escapeHtml(actuatorGroupLabel(group))}"> <optgroup label="${escapeHtml(actuatorGroupLabel(group))}">
${filtered ${visible
.filter(entity => entity.domain === group) .filter(entity => entity.domain === group)
.map(entity => `<option value="${escapeHtml(entity.entity_id)}">${escapeHtml(entityLabel(entity))}</option>`) .map(entity => `<option value="${escapeHtml(entity.entity_id)}">${escapeHtml(entityLabel(entity))}</option>`)
.join("")} .join("")}
@@ -574,6 +592,10 @@ async function showActuator(actuatorId, evaluationMessage = "") {
${prediction ${prediction
? `<p><strong>${escapeHtml(prediction.target_state)}</strong> mit ${Math.round(prediction.confidence * 100)} % Sicherheit. ${escapeHtml(prediction.reason)} <span class="${prediction.executed ? "ok" : "muted"}">${escapeHtml(prediction.execution_reason)}</span></p>` ? `<p><strong>${escapeHtml(prediction.target_state)}</strong> mit ${Math.round(prediction.confidence * 100)} % Sicherheit. ${escapeHtml(prediction.reason)} <span class="${prediction.executed ? "ok" : "muted"}">${escapeHtml(prediction.execution_reason)}</span></p>`
: "<p class='muted'>Aktuell ist kein gelerntes Handlungsmuster fällig.</p>"} : "<p class='muted'>Aktuell ist kein gelerntes Handlungsmuster fällig.</p>"}
<div class="actions">
<button class="secondary" onclick="sendFeedback('${escapeHtml(record.actuator_entity_id)}', true)">Vorhersage korrekt</button>
<button class="secondary" onclick="sendFeedback('${escapeHtml(record.actuator_entity_id)}', false)">Vorhersage falsch</button>
</div>
<h3>Passende Home-Assistant-Automationen</h3> <h3>Passende Home-Assistant-Automationen</h3>
<p class="muted">Bei einer Übernahme pausiert SillyHome diese Automationen. Beim Stoppen können sie gezielt fortgesetzt werden.</p> <p class="muted">Bei einer Übernahme pausiert SillyHome diese Automationen. Beim Stoppen können sie gezielt fortgesetzt werden.</p>
${automationControls} ${automationControls}
@@ -632,6 +654,23 @@ async function evaluateActuator(actuatorId) {
} }
} }
async function sendFeedback(actuatorId, correct) {
const expectedState = correct ? null : prompt("Welcher Zustand wäre korrekt gewesen? Leer lassen, wenn nur abwerten.");
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/feedback`, {
method: "POST",
body: JSON.stringify({
correct,
expected_state: expectedState || null,
}),
});
await loadConfiguredActuators();
await showActuator(actuatorId, correct ? "Vorhersage als korrekt gelernt." : "Vorhersage als falsch markiert.");
} catch (error) {
alert(error.message);
}
}
async function setActivation(actuatorId, active, pauseMatchingAutomations, restorePausedAutomations) { async function setActivation(actuatorId, active, pauseMatchingAutomations, restorePausedAutomations) {
const question = active const question = active
? pauseMatchingAutomations ? pauseMatchingAutomations

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "sillyhome-next" name = "sillyhome-next"
version = "0.7.10" version = "0.7.12"
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

@@ -9,6 +9,7 @@ from app.actuators.lifecycle import ActuatorReconciliationService
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.config import Settings from app.config import Settings
from app.api.v1.actuators import _deduplicate_actuator_ids
from app.ha.discovery import DiscoveredEntity from app.ha.discovery import DiscoveredEntity
from app.ha.discovery import discover_entities from app.ha.discovery import discover_entities
from app.ha.history import ( from app.ha.history import (
@@ -227,3 +228,37 @@ def test_context_options_returns_learnable_entities(tmp_path: Path) -> None:
assert "sensor.abstellkammer_illuminance" in entity_ids assert "sensor.abstellkammer_illuminance" in entity_ids
assert "binary_sensor.abstellkammer_motion" in entity_ids assert "binary_sensor.abstellkammer_motion" in entity_ids
assert "sensor.pfsense_interface_vpn_inbytes" not in entity_ids assert "sensor.pfsense_interface_vpn_inbytes" not in entity_ids
def test_actuator_discovery_prefers_light_over_duplicate_switch() -> None:
entities = {
"light.schreibtisch": HaEntitySummary(
entity_id="light.schreibtisch",
domain="light",
friendly_name="Schreibtisch Licht",
device_id="device-1",
),
"switch.schreibtisch": HaEntitySummary(
entity_id="switch.schreibtisch",
domain="switch",
friendly_name="Schreibtisch Schalter",
device_id="device-1",
),
"cover.rollladen": HaEntitySummary(
entity_id="cover.rollladen",
domain="cover",
friendly_name="Rollladen",
device_id="device-2",
),
}
result = _deduplicate_actuator_ids(
[
("switch.schreibtisch", "switch_socket"),
("light.schreibtisch", "light"),
("cover.rollladen", "cover_shutter"),
],
entities,
)
assert result == ["cover.rollladen", "light.schreibtisch"]

View File

@@ -27,6 +27,7 @@ class FakeHaReader(HaReader):
entity_id="sensor.temperature", entity_id="sensor.temperature",
domain="sensor", domain="sensor",
device_class="temperature", device_class="temperature",
category="temperature",
role=EntityRole.MEASUREMENT, role=EntityRole.MEASUREMENT,
learnable=True, learnable=True,
reason="Numerischer Messsensor für Zeitreihen und Training.", reason="Numerischer Messsensor für Zeitreihen und Training.",
@@ -116,6 +117,7 @@ def test_discovery_filters_entities() -> None:
"device_class": "temperature", "device_class": "temperature",
"state_class": None, "state_class": None,
"unit_of_measurement": None, "unit_of_measurement": None,
"category": "temperature",
"role": "measurement", "role": "measurement",
"learnable": True, "learnable": True,
"reason": "Numerischer Messsensor für Zeitreihen und Training.", "reason": "Numerischer Messsensor für Zeitreihen und Training.",

View File

@@ -8,6 +8,7 @@ import pytest
from app.actuators.models import ( from app.actuators.models import (
BehaviorMode, BehaviorMode,
BehaviorPattern, BehaviorPattern,
BehaviorPrediction,
BehaviorState, BehaviorState,
BehaviorStatus, BehaviorStatus,
ExecutionEvent, ExecutionEvent,
@@ -212,6 +213,125 @@ def test_engine_counts_known_automation_actions_like_manual_actions(
assert {pattern.weight for pattern in trained.behavior.patterns} == {1.0} assert {pattern.weight for pattern in trained.behavior.patterns} == {1.0}
def test_feedback_marks_prediction_correct_as_learning_pattern(
tmp_path: Path,
) -> None:
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store)
record = store.configure("light.office")
record = record.model_copy(
update={
"assignment": record.assignment.model_copy(
update={
"selected_context_entity_ids": [
"binary_sensor.office_presence"
],
}
),
"behavior": record.behavior.model_copy(
update={
"prediction": BehaviorPrediction(
target_state="on",
confidence=0.9,
generated_at=now,
reason="test",
)
}
),
}
)
store.upsert(record)
reader = FakeBehaviorReader(
entities=[
HaEntitySummary(entity_id="light.office", domain="light", state="off"),
HaEntitySummary(
entity_id="binary_sensor.office_presence",
domain="binary_sensor",
state="on",
),
],
history=[],
logbook=[],
)
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
result = engine.record_feedback("light.office", correct=True)
assert result.behavior.patterns[-1].target_state == "on"
assert result.behavior.patterns[-1].context_states == {
"binary_sensor.office_presence": "on"
}
assert result.behavior.patterns[-1].source == "user_feedback"
assert result.behavior.reason == "Vorhersage wurde vom Nutzer als korrekt bestätigt."
def test_feedback_marks_prediction_wrong_and_adds_correction(
tmp_path: Path,
) -> None:
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store)
record = store.configure("light.office")
record = record.model_copy(
update={
"assignment": record.assignment.model_copy(
update={
"selected_context_entity_ids": [
"binary_sensor.office_presence"
],
}
),
"behavior": record.behavior.model_copy(
update={
"patterns": [
BehaviorPattern(
target_state="on",
minute_of_day=60,
weekday=0,
context_states={"binary_sensor.office_presence": "on"},
source="automation",
weight=1.0,
observed_at=now - timedelta(days=1),
)
],
"prediction": BehaviorPrediction(
target_state="on",
confidence=0.9,
generated_at=now,
reason="test",
),
}
),
}
)
store.upsert(record)
reader = FakeBehaviorReader(
entities=[
HaEntitySummary(entity_id="light.office", domain="light", state="off"),
HaEntitySummary(
entity_id="binary_sensor.office_presence",
domain="binary_sensor",
state="on",
),
],
history=[],
logbook=[],
)
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
result = engine.record_feedback(
"light.office",
correct=False,
expected_state="off",
)
assert result.behavior.patterns[0].weight == 0.1
assert result.behavior.patterns[-1].target_state == "off"
assert result.behavior.patterns[-1].source == "user_correction"
assert result.behavior.reason == "Vorhersage wurde vom Nutzer als falsch markiert."
def test_engine_learns_causal_automation_with_activation_credit( def test_engine_learns_causal_automation_with_activation_credit(
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:

View File

@@ -74,3 +74,56 @@ def test_discovery_filters_domain_and_learnable() -> None:
result = discover_entities(entities, domains={" SENSOR "}, learnable=True) result = discover_entities(entities, domains={" SENSOR "}, learnable=True)
assert [item.entity_id for item in result] == ["sensor.temperature"] assert [item.entity_id for item in result] == ["sensor.temperature"]
@pytest.mark.parametrize(
("entity", "category"),
[
(
HaEntitySummary(entity_id="climate.bad", domain="climate"),
"heating",
),
(
HaEntitySummary(entity_id="lock.front_door", domain="lock"),
"lock",
),
(
HaEntitySummary(entity_id="input_boolean.sleep_mode", domain="input_boolean"),
"helper",
),
(
HaEntitySummary(
entity_id="sensor.brightness",
domain="sensor",
device_class="illuminance",
),
"brightness",
),
(
HaEntitySummary(
entity_id="binary_sensor.motion",
domain="binary_sensor",
device_class="motion",
),
"presence_motion",
),
],
)
def test_classify_entity_categories(entity: HaEntitySummary, category: str) -> None:
assert classify_entity(entity).category == category
@pytest.mark.parametrize(
"entity",
[
HaEntitySummary(entity_id="automation.lights", domain="automation"),
HaEntitySummary(entity_id="update.core", domain="update"),
],
)
def test_classify_excludes_non_actuator_management_entities(
entity: HaEntitySummary,
) -> None:
result = classify_entity(entity)
assert result.role is EntityRole.UNSUPPORTED
assert result.learnable is False