Compare commits

..

6 Commits

13 changed files with 552 additions and 69 deletions

View File

@@ -1,5 +1,44 @@
# Changelog
## 0.7.9 - 2026-06-15
- Event-basierte Vorhersagen verwenden den frischen Sensorzustand direkt aus
dem Home-Assistant-WebSocket-Event, damit Kontextwechsel ohne REST-Race sofort
bewertet und geschaltet werden können
- Regressionstest stellt sicher, dass ein Türsensor-Event trotz veraltetem
HA-Snapshot direkt `light.turn_on` auslöst
## 0.7.8 - 2026-06-15
- Home-Assistant-WebSocket-Listener deaktiviert den clientseitigen Keepalive-
Ping, damit stabile HA-Verbindungen nicht durch Ping-Timeouts ständig neu
aufgebaut werden
- Fallback-Auswertung läuft bei getrenntem WebSocket kurzfristig alle 5 Sekunden,
damit übernommene Aktoren nicht ohne Steuerung bleiben
## 0.7.7 - 2026-06-15
- WebSocket-State-Changes lesen jetzt das echte Home-Assistant-Eventformat
(`event.data.entity_id`), damit Kontextwechsel wie Türsensoren sofort
Vorhersagen und Schaltungen auslösen statt erst beim nächsten Statusabruf
## 0.7.6 - 2026-06-14
- Kontextvorschläge blenden zusätzlich Batterie-, Status-, Node-, Last-Seen-
und Basic-Entities aus, sofern sie nicht bewusst manuell ausgewählt wurden
## 0.7.5 - 2026-06-14
- Kontextvorschläge weiter geschärft: Standardliste zeigt nur gleiche Räume,
gemeinsame Geräte/Tokens oder echte globale Außenwerte
- Diagnosewerte wie MQTT-, WiFi-, Restart- und Connect-Zähler werden nicht mehr
als fachliche Kontextvorschläge angeboten
## 0.7.4 - 2026-06-14
- Kontext-Auswahl liefert jetzt aktorbezogene Vorschläge statt einer pauschalen
Roh-Liste aller Sensoren und Zustände
- Dashboard-Auswahl für Aktoren und Kontext nach Typ/Kategorie gruppiert und
durchsuchbar; lange Listen werden begrenzt statt mobil unbedienbar zu werden
- Manuelle Entity-ID-Eingabe ergänzt, damit relevante Sensoren auch ohne
Dropdown-Treffer gespeichert werden können
- Irrelevante System-/VPN-/pfSense-Sensoren tauchen bei Lichtaktoren ohne
fachlichen Bezug nicht mehr als Standardvorschläge auf
## 0.7.3 - 2026-06-14
- Automatische Kontextzuordnung ignoriert generische Bereiche wie `Monitoring`,
damit System-/Disk-/Überhitzungssensoren nicht fälschlich Lichtaktoren erklären

View File

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

View File

@@ -65,6 +65,45 @@ _CONTEXT_AUTO_ACCEPT_SCORE = 0.78
_CONTEXT_AUTO_ACCEPT_MIN_SCORE = 0.3
_MAX_CONTEXT_SELECTIONS = 5
_AUDIT_LIMIT = 20
_MANUAL_CONTEXT_DOMAINS = frozenset({
"binary_sensor",
"climate",
"cover",
"device_tracker",
"fan",
"humidifier",
"light",
"person",
"sensor",
"switch",
"weather",
})
_CONTEXT_SUGGESTION_LIMIT = 120
_OUTDOOR_TOKENS = frozenset({"aussen", "außen", "outdoor", "garten", "terrasse", "balkon"})
_DIAGNOSTIC_TOKENS = frozenset({
"basic",
"battery",
"connect",
"count",
"diagnostic",
"firmware",
"gesehen",
"last",
"linkquality",
"knoten",
"knotens",
"mqtt",
"node",
"reason",
"restart",
"rssi",
"signal",
"ssid",
"status",
"uptime",
"wifi",
"zuletzt",
})
class ActuatorReconciliationService:
@@ -91,6 +130,51 @@ class ActuatorReconciliationService:
def get_actuator(self, actuator_entity_id: str) -> ActuatorRecord:
return self._store.get(actuator_entity_id)
def suggest_context_options(
self,
actuator_entity_id: str,
*,
limit: int = _CONTEXT_SUGGESTION_LIMIT,
) -> list[HaEntitySummary]:
entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()}
discovered = {entity.entity_id: entity for entity in self._ha_reader.discover()}
actuator = entities.get(actuator_entity_id)
if actuator is None:
raise KeyError("Aktuator-Konfiguration nicht gefunden.")
selected_ids = _selected_context_ids(self._store.get(actuator_entity_id))
ranked: list[tuple[float, str, HaEntitySummary]] = []
for entity in entities.values():
if entity.entity_id == actuator_entity_id or entity.domain not in _MANUAL_CONTEXT_DOMAINS:
continue
role = _manual_context_role(entity, discovered.get(entity.entity_id))
score, _ = _score_candidate(
actuator,
entity,
role,
context=role is not EntityRole.MEASUREMENT,
)
selected = entity.entity_id in selected_ids
if selected:
score = max(score, 1.0)
if not selected and (
_is_diagnostic_context(entity)
or not _has_context_relationship(actuator, entity)
):
continue
if not selected and score < 0.1:
continue
ranked.append((score, _context_sort_group(entity), entity))
ranked.sort(
key=lambda item: (
-item[0],
item[1],
item[2].area_name or "",
item[2].friendly_name or item[2].entity_id,
item[2].entity_id,
)
)
return [entity for _, _, entity in ranked[:limit]]
def delete_actuator(self, actuator_entity_id: str) -> None:
model_id = model_id_for_actuator(actuator_entity_id)
self._registry.archive(model_id)
@@ -592,6 +676,73 @@ def _filter_candidates(
return result
def _selected_context_ids(record: ActuatorRecord) -> set[str]:
result = set(record.assignment.selected_context_entity_ids)
if record.assignment.selected_numeric_entity_id:
result.add(record.assignment.selected_numeric_entity_id)
if record.manual_override is not None:
result.update(record.manual_override.context_entity_ids)
if record.manual_override.numeric_entity_id:
result.add(record.manual_override.numeric_entity_id)
return result
def _manual_context_role(
entity: HaEntitySummary,
discovered: DiscoveredEntity | None,
) -> EntityRole:
if discovered is not None and discovered.role is not EntityRole.UNSUPPORTED:
return discovered.role
if entity.domain == "sensor":
return EntityRole.MEASUREMENT
if entity.domain == "binary_sensor":
return EntityRole.BINARY_CONTEXT
return EntityRole.CONTEXT
def _context_sort_group(entity: HaEntitySummary) -> str:
device_class = entity.device_class or ""
if device_class in {"motion", "occupancy", "presence"}:
return "01_presence"
if device_class in {"illuminance"}:
return "02_brightness"
if device_class in {"door", "garage_door", "opening", "window"}:
return "03_opening"
if device_class in {"humidity", "moisture"}:
return "04_humidity"
if device_class in {"power", "energy", "current", "voltage"}:
return "05_power"
if entity.domain in {"light", "switch"}:
return "06_states"
return f"20_{entity.domain}_{device_class}"
def _is_diagnostic_context(entity: HaEntitySummary) -> bool:
tokens = _metadata_tokens(entity, include_stopwords=True)
return bool(tokens.intersection(_DIAGNOSTIC_TOKENS))
def _has_context_relationship(actuator: HaEntitySummary, entity: HaEntitySummary) -> bool:
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
):
return True
if actuator.device_id and entity.device_id and actuator.device_id == entity.device_id:
return True
if actuator.device_name and entity.device_name and actuator.device_name == entity.device_name:
return True
if _metadata_tokens(actuator).intersection(_metadata_tokens(entity)):
return True
entity_tokens = _metadata_tokens(entity, include_stopwords=True)
return bool(
entity_tokens.intersection(_OUTDOOR_TOKENS)
and entity.device_class in {"illuminance", "humidity", "temperature"}
)
def _score_candidate(
actuator: HaEntitySummary,
entity: HaEntitySummary,
@@ -637,6 +788,13 @@ def _score_candidate(
if context and role is EntityRole.BINARY_CONTEXT:
score += 0.05
evidence.append("Binärer Kontextsensor bevorzugt für Zusatzkontext.")
if entity_tokens.intersection(_OUTDOOR_TOKENS) and entity.device_class in {
"illuminance",
"humidity",
"temperature",
}:
score += 0.1
evidence.append("Außenmesswert ist oft als übergreifender Kontext relevant.")
return round(min(score, 1.0), 4), evidence
@@ -698,7 +856,7 @@ def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]:
return frozenset(mapping.get(domain, {"power", "energy", "temperature"}))
def _metadata_tokens(entity: HaEntitySummary) -> set[str]:
def _metadata_tokens(entity: HaEntitySummary, *, include_stopwords: bool = False) -> set[str]:
raw_values = [
entity.entity_id,
entity.friendly_name,
@@ -710,7 +868,7 @@ def _metadata_tokens(entity: HaEntitySummary) -> set[str]:
if value is None:
continue
for token in _TOKEN_PATTERN.findall(value.lower().replace("_", " ")):
if len(token) < 3 or token in _STOPWORDS:
if len(token) < 3 or (not include_stopwords and token in _STOPWORDS):
continue
tokens.add(token)
return tokens

View File

@@ -14,19 +14,6 @@ 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):
@@ -62,19 +49,16 @@ def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaE
@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,
),
)
def context_options(
request: Request,
actuator_entity_id: str | None = Query(default=None, pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$"),
) -> list[HaEntitySummary]:
if actuator_entity_id is None:
return []
try:
return _service(request).suggest_context_options(actuator_entity_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("", response_model=list[ActuatorRecord])

View File

@@ -187,7 +187,13 @@ class BehaviorEngine:
results.append(record)
return results
def evaluate(self, actuator_entity_id: str) -> ActuatorRecord:
def evaluate(
self,
actuator_entity_id: str,
*,
context_state_overrides: dict[str, str | None] | None = None,
context_changed_at_overrides: dict[str, datetime | None] | None = None,
) -> ActuatorRecord:
record = self._store.get(actuator_entity_id)
now = datetime.now(timezone.utc)
try:
@@ -230,6 +236,22 @@ class BehaviorEngine:
entity_id: entities[entity_id].last_changed
for entity_id in current_context
}
selected_context_ids = {
entity_id
for entity_id in (
[
record.assignment.selected_numeric_entity_id,
*record.assignment.selected_context_entity_ids,
]
)
if entity_id
}
for entity_id, state in (context_state_overrides or {}).items():
if entity_id in selected_context_ids and state is not None:
current_context[entity_id] = state
for entity_id, changed_at in (context_changed_at_overrides or {}).items():
if entity_id in current_context:
current_context_changed_at[entity_id] = changed_at or now
prediction = predict_behavior(
record.behavior.patterns,
current_context=current_context,
@@ -622,6 +644,8 @@ class BehaviorEngine:
except Exception:
logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", record.actuator_entity_id)
return
event_state = _event_state(new_state)
event_changed_at = _event_changed_at(new_state) or datetime.now(timezone.utc)
# Kontext-Entity: alle Aktoren finden, die diesen Kontext nutzen
affected_actuators = [
record.actuator_entity_id
@@ -633,11 +657,37 @@ class BehaviorEngine:
]
for actuator_entity_id in affected_actuators:
try:
self.evaluate(actuator_entity_id)
self.evaluate(
actuator_entity_id,
context_state_overrides={entity_id: event_state},
context_changed_at_overrides={entity_id: event_changed_at},
)
except Exception:
logger.exception("Event-basierte Vorhersage fehlgeschlagen für %s", actuator_entity_id)
def _event_state(new_state: dict[str, object] | None) -> str | None:
if not isinstance(new_state, dict):
return None
state = new_state.get("state")
return state if isinstance(state, str) else None
def _event_changed_at(new_state: dict[str, object] | None) -> datetime | None:
if not isinstance(new_state, dict):
return None
value = new_state.get("last_changed") or new_state.get("last_updated")
if not isinstance(value, str):
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed
def predict_behavior(
patterns: list[BehaviorPattern],
*,

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.3",
version="0.7.9",
lifespan=lifespan,
)
app.state.settings = load_settings()
@@ -171,7 +171,7 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
if ws_status is not None:
ws_status.status = "connecting"
try:
async with websockets.connect(ws_url) as websocket:
async with websockets.connect(ws_url, ping_interval=None) as websocket:
auth_required_msg = await websocket.recv()
auth_required_data = json.loads(auth_required_msg)
if auth_required_data.get("type") != "auth_required":
@@ -213,12 +213,20 @@ async def _ha_event_listener(app: FastAPI, client: HaClient) -> None:
event = data.get("event", {})
if event.get("event_type") != "state_changed":
continue
entity_id = event.get("entity_id")
event_data = event.get("data", {})
if not isinstance(event_data, dict):
logger.warning("State-Changed-Event ohne gültige Daten empfangen")
continue
entity_id = event_data.get("entity_id")
if not entity_id:
continue
# Prüfe, ob Entity ein Aktor oder relevanter Kontext ist
# Sofortige Vorhersage für betroffene Aktoren auslösen
await asyncio.to_thread(engine.handle_state_change, entity_id, event.get("new_state"))
await asyncio.to_thread(
engine.handle_state_change,
entity_id,
event_data.get("new_state"),
)
except json.JSONDecodeError:
logger.warning("Ungültige JSON-Nachricht von HA-WebSocket")
except Exception as exc:
@@ -244,7 +252,13 @@ async def _fallback_prediction(app: FastAPI) -> None:
Dies verhindert kompletten Ausfall der Vorhersagen bei Netzwerkproblemen.
"""
while True:
await asyncio.sleep(app.state.settings.prediction_interval_seconds)
ws_status = getattr(app.state, "ws_status", None)
websocket_connected = ws_status is not None and ws_status.status == "connected"
await asyncio.sleep(
app.state.settings.prediction_interval_seconds
if websocket_connected
else min(5, app.state.settings.prediction_interval_seconds)
)
# Nur ausführen, wenn WebSocket nicht verbunden ist
ws_status = getattr(app.state, "ws_status", None)
if ws_status is None or ws_status.status != "connected":

View File

@@ -51,6 +51,10 @@
.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; }
.inline-controls { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:8px; margin:8px 0; }
.manual-entry { min-height:80px; resize:vertical; }
textarea { box-sizing:border-box; width:100%; border-radius:10px; border:1px solid #3b4b5b; padding:12px; background:#101820; color:#fff; font:inherit; }
optgroup { color:#cfe0ec; background:#101820; }
code { color:#cfe0ec; overflow-wrap:anywhere; }
@media (max-width: 760px) {
header { padding:18px 14px; }
@@ -123,6 +127,23 @@
<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>
<div class="inline-controls">
<div>
<label for="actuator-domain-filter">Typ</label>
<select id="actuator-domain-filter" onchange="renderActuatorSelect()">
<option value="">Alle steuerbaren Typen</option>
<option value="light">Lichter</option>
<option value="switch">Schalter / Helper</option>
<option value="cover">Rollläden / Cover</option>
<option value="fan">Lüftung / Ventilatoren</option>
<option value="humidifier">Befeuchter / Entfeuchter</option>
</select>
</div>
<div>
<label for="actuator-search">Liste durchsuchen</label>
<input id="actuator-search" placeholder="Raum, Gerät oder Entity" oninput="renderActuatorSelect()" autocomplete="off">
</div>
</div>
<label for="actuator-select">Oder aus Liste wählen</label>
<select id="actuator-select" onchange="selectActuatorFromList()">
<option value="">Geräteliste wird geladen ...</option>
@@ -153,6 +174,7 @@ const escapeHtml = value => String(value ?? "")
let currentActuatorId = null;
let actuatorChoices = [];
let contextOptions = [];
let manualContextState = {options: [], selected: new Set()};
async function api(path, options = {}) {
const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options});
@@ -194,6 +216,61 @@ function predictionLabel(record) {
: "Keine fällige Aktion";
}
function entityLabel(entity) {
const area = entity.area_name || "Ohne Bereich";
const name = entity.friendly_name || entity.entity_id;
return `${area} - ${name} (${entity.entity_id})`;
}
function normalizedSearch(value) {
return String(value || "").toLowerCase().replaceAll("_", " ");
}
function matchesSearch(entity, query) {
if (!query) return true;
return normalizedSearch([
entity.entity_id,
entity.friendly_name,
entity.area_name,
entity.device_name,
entity.device_class,
entity.domain,
].filter(Boolean).join(" ")).includes(query);
}
function categoryForEntity(entity) {
const cls = entity.device_class || "";
if (entity.domain === "light") return "Lichtzustände";
if (entity.domain === "switch") return "Schalter / Helper";
if (["motion", "occupancy", "presence"].includes(cls)) return "PIR / Präsenz";
if (["illuminance"].includes(cls)) return "Helligkeit";
if (["door", "garage_door", "opening", "window"].includes(cls)) return "Tür / Fenster";
if (["humidity", "moisture"].includes(cls)) return "Luftfeuchtigkeit";
if (["temperature"].includes(cls)) return "Temperatur";
if (["power", "energy", "current", "voltage"].includes(cls)) return "Strom / Energie";
if (entity.domain === "binary_sensor") return "Binäre Sensoren";
if (entity.domain === "sensor") return "Weitere Messsensoren";
return "Weitere Zustände";
}
function optionGroups(entities, selectedIds = new Set()) {
const groups = new Map();
for (const entity of entities) {
const category = categoryForEntity(entity);
if (!groups.has(category)) groups.set(category, []);
groups.get(category).push(entity);
}
return Array.from(groups.entries()).map(([label, items]) => `
<optgroup label="${escapeHtml(label)}">
${items.map(entity => `
<option value="${escapeHtml(entity.entity_id)}" ${selectedIds.has(entity.entity_id) ? "selected" : ""}>
${escapeHtml(entityLabel(entity))}
</option>
`).join("")}
</optgroup>
`).join("");
}
async function loadOverview() {
const status = document.getElementById("status");
const chips = document.getElementById("status-chips");
@@ -217,7 +294,7 @@ async function loadOverview() {
status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
chips.innerHTML = "";
}
await Promise.all([loadActuatorDiscovery(), loadContextOptions(), loadConfiguredActuators()]);
await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators()]);
}
async function loadActuatorDiscovery() {
@@ -230,24 +307,53 @@ async function loadActuatorDiscovery() {
]);
const configuredIds = new Set(configured.map(record => record.actuator_entity_id));
actuatorChoices = available.filter(entity => !configuredIds.has(entity.entity_id));
options.innerHTML = actuatorChoices.map(entity =>
options.innerHTML = actuatorChoices.slice(0, 120).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("");
renderActuatorSelect();
} catch (error) {
options.innerHTML = "";
select.innerHTML = `<option value="">Geräteliste konnte nicht geladen werden</option>`;
}
}
async function loadContextOptions() {
function actuatorGroupLabel(domain) {
const labels = {
light: "Lichter",
switch: "Schalter / Helper",
cover: "Rollläden / Cover",
fan: "Lüftung / Ventilatoren",
humidifier: "Befeuchter / Entfeuchter",
};
return labels[domain] || domain;
}
function renderActuatorSelect() {
const select = document.getElementById("actuator-select");
if (!select) return;
const domain = document.getElementById("actuator-domain-filter")?.value || "";
const query = normalizedSearch(document.getElementById("actuator-search")?.value || "");
const filtered = actuatorChoices
.filter(entity => !domain || entity.domain === domain)
.filter(entity => matchesSearch(entity, query))
.slice(0, 120);
const domains = [...new Set(filtered.map(entity => entity.domain))].sort();
select.innerHTML = [
`<option value="">${filtered.length ? "Gerät auswählen ..." : "Keine passenden Geräte gefunden"}</option>`,
...domains.map(group => `
<optgroup label="${escapeHtml(actuatorGroupLabel(group))}">
${filtered
.filter(entity => entity.domain === group)
.map(entity => `<option value="${escapeHtml(entity.entity_id)}">${escapeHtml(entityLabel(entity))}</option>`)
.join("")}
</optgroup>
`),
].join("");
}
async function loadContextOptions(actuatorId) {
try {
contextOptions = await api("v1/actuators/context-options");
contextOptions = await api(`v1/actuators/context-options?actuator_entity_id=${encodeURIComponent(actuatorId)}`);
} catch (error) {
contextOptions = [];
}
@@ -258,6 +364,31 @@ function selectActuatorFromList() {
if (value) document.getElementById("actuator-input").value = value;
}
function renderManualContextSelect() {
const select = document.getElementById("manual-context-select");
if (!select) return;
const category = document.getElementById("manual-context-category")?.value || "";
const query = normalizedSearch(document.getElementById("manual-context-filter")?.value || "");
const selectedNow = new Set([
...manualContextState.selected,
...Array.from(select.selectedOptions).map(option => option.value),
]);
const filtered = manualContextState.options
.filter(entity => !category || categoryForEntity(entity) === category)
.filter(entity => matchesSearch(entity, query))
.slice(0, 80);
select.innerHTML = filtered.length
? optionGroups(filtered, selectedNow)
: `<option value="">Keine passenden Vorschläge</option>`;
}
function parseEntityIds(value) {
return String(value || "")
.split(/[\s,;]+/)
.map(item => item.trim())
.filter(Boolean);
}
async function configureActuator() {
const actuatorId = (
document.getElementById("actuator-input").value.trim()
@@ -328,6 +459,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
} catch (_) {
record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
}
await loadContextOptions(actuatorId);
const contexts = [
record.assignment.selected_numeric_entity_id,
...record.assignment.selected_context_entity_ids,
@@ -343,32 +475,50 @@ async function showActuator(actuatorId, evaluationMessage = "") {
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 suggestedIds = new Set(contextOptions.map(entity => entity.entity_id));
const manualOnlyIds = [
record.assignment.selected_numeric_entity_id,
...manualContextIds,
].filter(entityId => entityId && !suggestedIds.has(entityId));
const contextCategories = [...new Set(contextOptions
.filter(entity => entity.entity_id !== record.actuator_entity_id)
.map(categoryForEntity))]
.sort();
manualContextState = {
options: contextOptions.filter(entity => entity.entity_id !== record.actuator_entity_id),
selected: manualContextIds,
};
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>
<p class="muted">Die Vorschläge sind aktorbezogen vorsortiert. Wenn etwas fehlt, trage die Entity-ID unten manuell ein, z. B. PIR, Helligkeit außen, Luftfeuchtigkeit oder Lichtzustände.</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("")}
${optionGroups(numericOptions, new Set([record.assignment.selected_numeric_entity_id].filter(Boolean)))}
</select>
<label for="manual-context-select">Zusätzliche Kontext-Entities</label>
<div class="inline-controls">
<div>
<label for="manual-context-category">Kategorie</label>
<select id="manual-context-category" onchange="renderManualContextSelect()">
<option value="">Alle relevanten Vorschläge</option>
${contextCategories.map(category => `<option value="${escapeHtml(category)}">${escapeHtml(category)}</option>`).join("")}
</select>
</div>
<div>
<label for="manual-context-filter">Vorschläge durchsuchen</label>
<input id="manual-context-filter" placeholder="z. B. treppe, bewegung, lux" oninput="renderManualContextSelect()" autocomplete="off">
</div>
</div>
<label for="manual-context-select">Zusätzliche Kontext-Entities aus Vorschlägen</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("")}
${optionGroups(manualContextState.options.slice(0, 80), manualContextIds)}
</select>
<label for="manual-context-freeform">Entity-IDs manuell ergänzen</label>
<textarea id="manual-context-freeform" class="manual-entry" placeholder="Eine oder mehrere Entity-IDs, getrennt durch Komma, Leerzeichen oder neue Zeilen">${escapeHtml(manualOnlyIds.join("\n"))}</textarea>
<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>
<button class="secondary" onclick="loadContextOptions('${escapeHtml(record.actuator_entity_id)}').then(() => showActuator('${escapeHtml(record.actuator_entity_id)}'))">Vorschläge neu laden</button>
</div>
</div>
`;
@@ -439,9 +589,14 @@ async function showActuator(actuatorId, evaluationMessage = "") {
async function saveManualAssignment(actuatorId) {
const numericEntityId = document.getElementById("manual-numeric-select").value || null;
const contextEntityIds = Array.from(
const selectedContextIds = Array.from(
document.getElementById("manual-context-select").selectedOptions,
).map(option => option.value);
).map(option => option.value).filter(value => value.includes("."));
const freeformContextIds = parseEntityIds(
document.getElementById("manual-context-freeform").value,
);
const contextEntityIds = [...new Set([...selectedContextIds, ...freeformContextIds])]
.filter(entityId => entityId !== numericEntityId);
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/assignment`, {
method: "POST",

View File

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

View File

@@ -78,7 +78,7 @@ def _service(
model_store=str(tmp_path / "models"),
automation_store=str(tmp_path / "automations"),
actuator_store=str(tmp_path / "actuators"),
history_days=14,
history_days=31,
min_training_points=5,
retrain_stale_hours=24,
reconcile_interval_seconds=900,

View File

@@ -115,6 +115,14 @@ def _install_service(tmp_path: Path) -> None:
friendly_name="Abstellkammer Bewegung",
area_name="Abstellkammer",
),
HaEntitySummary(
entity_id="sensor.pfsense_interface_vpn_inbytes",
domain="sensor",
device_class="data_size",
state_class="measurement",
unit_of_measurement="KiB",
friendly_name="pfSense Interface VPN inbytes",
),
]
settings = Settings(
ha_url="http://ha.local",
@@ -208,10 +216,14 @@ 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")
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
response = client.get(
"/v1/actuators/context-options",
params={"actuator_entity_id": "light.abstellkammer"},
)
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
assert "sensor.pfsense_interface_vpn_inbytes" not in entity_ids

View File

@@ -523,3 +523,67 @@ def test_prediction_ignores_stale_causal_context_state() -> None:
min_support=1,
window_minutes=30,
) is None
def test_state_change_uses_websocket_context_state_for_immediate_action(
tmp_path: Path,
) -> None:
now = datetime.now(timezone.utc).replace(microsecond=0)
settings = _settings(tmp_path)
store = ActuatorStore(settings.actuator_store)
record = store.configure("light.storage")
record = record.model_copy(
update={
"assignment": record.assignment.model_copy(
update={
"selected_context_entity_ids": ["binary_sensor.storage_door"],
}
),
"behavior": record.behavior.model_copy(
update={
"mode": BehaviorMode.ACTIVE,
"status": BehaviorStatus.TRAINED,
"activation_ready": True,
"patterns": [
BehaviorPattern(
target_state="on",
minute_of_day=60,
weekday=0,
context_states={"binary_sensor.storage_door": "on"},
trigger_entity_id="binary_sensor.storage_door",
trigger_from_state="off",
trigger_to_state="on",
source="automation",
weight=1.0,
observed_at=now - timedelta(days=days_ago),
)
for days_ago in (3, 2, 1)
],
}
),
}
)
store.upsert(record)
reader = FakeBehaviorReader(
entities=[
HaEntitySummary(entity_id="light.storage", domain="light", state="off"),
HaEntitySummary(
entity_id="binary_sensor.storage_door",
domain="binary_sensor",
state="off",
last_changed=now - timedelta(minutes=5),
),
],
history=[],
logbook=[],
)
engine = BehaviorEngine(ha_reader=reader, store=store, settings=settings)
engine.handle_state_change(
"binary_sensor.storage_door",
{"state": "on", "last_changed": now.isoformat()},
)
assert reader.service_calls == [
("light", "turn_on", {"entity_id": "light.storage"})
]

View File

@@ -13,6 +13,7 @@ def test_dashboard_is_served_at_root() -> None:
assert "Gerät zum Lernen auswählen" 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 "Liste durchsuchen" in response.text
assert "Wie gewohnt bedienen" 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
@@ -23,6 +24,8 @@ def test_dashboard_is_served_at_root() -> None:
assert "Davon erkannte HA-Automationen" in response.text
assert "Aktuelle Situation auswerten" in response.text
assert "Kontext selbst festlegen" in response.text
assert "Entity-IDs manuell ergänzen" in response.text
assert "manual-context-freeform" 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

View File

@@ -55,18 +55,22 @@ def test_ha_event_listener_processes_state_change(tmp_path: Path) -> None:
'{"type":"auth_ok"}',
(
'{"type":"event","event":{"event_type":"state_changed",'
'"entity_id":"light.test","new_state":{"state":"on"}}}'
'"data":{"entity_id":"light.test","new_state":{"state":"on"}}}}'
),
asyncio.CancelledError(),
]
)
with patch("websockets.connect", return_value=fake_ws):
with patch("websockets.connect", return_value=fake_ws) as connect:
try:
await _ha_event_listener(mock_app, mock_client)
except asyncio.CancelledError:
pass
connect.assert_called_once_with(
"ws://homeassistant:8123/api/websocket",
ping_interval=None,
)
assert fake_ws.sent == [
{"type": "auth", "access_token": "test-token"},
{"id": 1, "type": "subscribe_events", "event_type": "state_changed"},