4 Commits

Author SHA1 Message Date
01e5464ec1 Add dashboard control and learning editor 2026-06-18 13:08:58 +02:00
937c79a19b Batch unrouted HA state events 2026-06-18 13:00:57 +02:00
149f11a18e Keep API responsive under HA events 2026-06-18 12:57:01 +02:00
38a85fce74 Pin addon image to release archive 2026-06-18 12:49:35 +02:00
6 changed files with 400 additions and 27 deletions

View File

@@ -2,7 +2,7 @@ FROM python:3.13-slim
WORKDIR /app
ARG SILLYHOME_FUTURE_REF=main
ARG SILLYHOME_FUTURE_REF=v2.0.0-alpha.8
RUN python -m pip install --no-cache-dir \
"http://192.168.6.31:3000/Otto/sillyhome-future/archive/${SILLYHOME_FUTURE_REF}.tar.gz"

View File

@@ -1,5 +1,5 @@
name: SillyHome Future
version: "2.0.0-alpha.4"
version: "2.0.0-alpha.8"
slug: sillyhome_future
description: Event-first SillyHome v2 test controller
url: http://192.168.6.31:3000/Otto/sillyhome-future

View File

@@ -7,6 +7,7 @@ from contextlib import asynccontextmanager, suppress
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field
from app.core.event_core import EventCore
from app.core.ha_client import FutureHaClient, HaClientConfig, service_for_state
@@ -14,11 +15,15 @@ from app.core.handoff import HandoffMatrix
from app.core.models import (
AuditEvent,
BackupBundle,
BehaviorPatternV2,
ControlProfile,
ControlState,
EntityState,
HandoffMode,
LearningProfile,
LearningState,
RuntimeState,
SafetyStage,
StateEvent,
)
from app.core.stores import FutureStores
@@ -29,6 +34,22 @@ handoff = HandoffMatrix()
ha_client: FutureHaClient | None = None
class ControlStageUpdate(BaseModel):
stage: SafetyStage
min_confidence: float = Field(default=0.82, ge=0.0, le=1.0)
manual_block: bool = False
cooldown_seconds: int = Field(default=900, ge=0)
class PatternCreateRequest(BaseModel):
trigger_entity_id: str
trigger_state: str | None = None
target_state: str
confidence: float = Field(default=0.9, ge=0.0, le=1.0)
support: int = Field(default=3, ge=1)
source: str = Field(default="dashboard", max_length=40)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
global ha_client
@@ -49,7 +70,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app = FastAPI(
title="SillyHome Future API",
description="SillyHome v2 event-core side project.",
version="2.0.0-alpha.4",
version="2.0.0-alpha.8",
lifespan=lifespan,
)
@@ -75,9 +96,15 @@ def dashboard_data() -> dict[str, object]:
learning = stores.learning()
control = stores.control()
latest_audit = runtime.audit[-20:]
actuator_entities = [
entity
for entity in runtime.entities.values()
if entity.domain in {"light", "switch", "fan", "cover", "humidifier"}
]
return {
"websocket_status": runtime.websocket_status,
"entity_count": len(runtime.entities),
"actuator_count": len(actuator_entities),
"learning_profiles": len(learning.profiles),
"control_profiles": len(control.profiles),
"rooms": list(learning.rooms.values()),
@@ -96,6 +123,23 @@ def get_runtime() -> RuntimeState:
return stores.runtime()
@app.get("/v2/entities", response_model=list[EntityState])
def list_entities(domain: str | None = None, q: str | None = None) -> list[EntityState]:
entities = list(stores.runtime().entities.values())
if domain:
wanted = {item.strip() for item in domain.split(",") if item.strip()}
entities = [entity for entity in entities if entity.domain in wanted]
if q:
needle = q.casefold()
entities = [
entity
for entity in entities
if needle in entity.entity_id.casefold()
or (entity.friendly_name is not None and needle in entity.friendly_name.casefold())
]
return sorted(entities, key=lambda entity: entity.entity_id)[:500]
@app.get("/v2/learning", response_model=LearningState)
def get_learning() -> LearningState:
return stores.learning()
@@ -119,6 +163,62 @@ def put_control(actuator_entity_id: str, profile: ControlProfile) -> ControlProf
return profile
@app.post("/v2/control/{actuator_entity_id}/stage", response_model=ControlProfile)
def update_control_stage(
actuator_entity_id: str,
update: ControlStageUpdate,
) -> ControlProfile:
state = stores.control()
profile = state.profiles.get(
actuator_entity_id,
ControlProfile(actuator_entity_id=actuator_entity_id),
)
updated = profile.model_copy(
update={
"stage": update.stage,
"min_confidence": update.min_confidence,
"manual_block": update.manual_block,
"cooldown_seconds": update.cooldown_seconds,
"handoff_mode": handoff.classify(profile),
}
)
state.profiles[actuator_entity_id] = updated
stores.save_control(state)
return updated
@app.post("/v2/learning/{actuator_entity_id}/patterns", response_model=LearningProfile)
def create_learning_pattern(
actuator_entity_id: str,
pattern: PatternCreateRequest,
) -> LearningProfile:
state = stores.learning()
profile = state.profiles.get(
actuator_entity_id,
LearningProfile(actuator_entity_id=actuator_entity_id),
)
updated = profile.model_copy(
update={
"patterns": [
*profile.patterns,
BehaviorPatternV2(
actuator_entity_id=actuator_entity_id,
target_state=pattern.target_state,
trigger_entity_id=pattern.trigger_entity_id,
trigger_state=pattern.trigger_state,
support=pattern.support,
confidence=pattern.confidence,
source=pattern.source,
),
],
"model_version": "dashboard-v1",
}
)
state.profiles[actuator_entity_id] = updated
stores.save_learning(state)
return updated
@app.post("/v2/handoff/{actuator_entity_id}/assume", response_model=ControlProfile)
def assume_control(actuator_entity_id: str) -> ControlProfile:
state = stores.control()
@@ -188,25 +288,76 @@ def _load_initial_ha_states(client: FutureHaClient) -> None:
async def _ha_listener_loop(client: FutureHaClient) -> None:
routed_triggers: set[str] = set()
next_route_refresh = 0.0
pending_unrouted: list[StateEvent] = []
last_unrouted_flush = 0.0
while True:
runtime = stores.runtime()
runtime.websocket_status = "connecting"
stores.save_runtime(runtime)
await asyncio.to_thread(_set_websocket_status, "connecting")
try:
async for event in client.listen_state_events():
runtime = stores.runtime()
runtime.websocket_status = "connected"
stores.save_runtime(runtime)
event_core.process_state_event(event, execute=_execute_ha_decision)
loop_time = asyncio.get_running_loop().time()
if loop_time >= next_route_refresh:
routed_triggers = await asyncio.to_thread(_routed_trigger_ids)
next_route_refresh = loop_time + 5
await asyncio.to_thread(_set_websocket_status, "connected")
if event.entity_id in routed_triggers:
if pending_unrouted:
await asyncio.to_thread(_merge_unrouted_events, pending_unrouted)
pending_unrouted = []
await asyncio.to_thread(
event_core.process_state_event,
event,
execute=_execute_ha_decision,
)
else:
pending_unrouted.append(event)
if len(pending_unrouted) >= 100 or loop_time - last_unrouted_flush >= 2:
await asyncio.to_thread(_merge_unrouted_events, pending_unrouted)
pending_unrouted = []
last_unrouted_flush = loop_time
await asyncio.sleep(0)
except asyncio.CancelledError:
raise
except Exception as exc:
runtime = stores.runtime()
runtime.websocket_status = f"reconnecting: {exc}"
stores.save_runtime(runtime)
await asyncio.to_thread(_set_websocket_status, f"reconnecting: {exc}")
await asyncio.sleep(2)
def _set_websocket_status(status: str) -> None:
runtime = stores.runtime()
if runtime.websocket_status == status:
return
runtime.websocket_status = status
stores.save_runtime(runtime)
def _routed_trigger_ids() -> set[str]:
result: set[str] = set()
for profile in stores.learning().profiles.values():
for pattern in profile.patterns:
if pattern.trigger_entity_id is not None:
result.add(pattern.trigger_entity_id)
return result
def _merge_unrouted_events(events: list[StateEvent]) -> None:
if not events:
return
runtime = stores.runtime()
for event in events:
runtime.entities[event.entity_id] = EntityState(
entity_id=event.entity_id,
domain=event.entity_id.split(".", 1)[0],
state=event.new_state,
changed_at=event.changed_at,
area_name=event.attributes.get("area_name"),
device_id=event.attributes.get("device_id"),
friendly_name=event.attributes.get("friendly_name"),
)
stores.save_runtime(runtime)
def _execute_ha_decision(decision: object) -> bool:
if ha_client is None or not hasattr(decision, "actuator_entity_id"):
return False
@@ -228,30 +379,155 @@ def _dashboard_html() -> str:
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SillyHome Future</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0; background: #101418; color: #eef3f6; }
main { max-width: 1080px; margin: 0 auto; padding: 24px; }
:root { color-scheme: dark; --bg: #101418; --panel: #171d23; --line: #2c3640; --text: #eef3f6; --muted: #9fb0bd; --accent: #42d392; --warn: #f3c969; }
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; background: var(--bg); color: var(--text); }
main { max-width: 1180px; margin: 0 auto; padding: 20px; }
h1 { font-size: 28px; margin: 0 0 18px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.card { border: 1px solid #2c3640; border-radius: 8px; padding: 14px; background: #171d23; }
h2 { font-size: 18px; margin: 24px 0 10px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; }
.split { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(320px, 1.2fr); gap: 14px; align-items: start; }
.card { border: 1px solid var(--line); border-radius: 8px; padding: 14px; background: var(--panel); }
.label { color: #9fb0bd; font-size: 13px; }
.value { font-size: 24px; margin-top: 4px; }
pre { white-space: pre-wrap; word-break: break-word; background: #0c1014; padding: 14px; border-radius: 8px; }
label { display: block; color: var(--muted); font-size: 13px; margin: 10px 0 5px; }
input, select, button { width: 100%; border: 1px solid var(--line); border-radius: 7px; background: #0c1014; color: var(--text); font: inherit; padding: 10px; }
button { cursor: pointer; background: #20303a; }
button:hover { border-color: var(--accent); }
.row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.stages { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-top: 8px; }
.stages button.active { border-color: var(--accent); color: var(--accent); }
.primary { background: #17402c; border-color: #256f4a; }
.status { min-height: 24px; color: var(--warn); margin-top: 10px; }
pre { max-height: 360px; overflow: auto; white-space: pre-wrap; word-break: break-word; background: #0c1014; padding: 14px; border-radius: 8px; }
@media (max-width: 800px) { .split, .row, .stages { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<main>
<h1>SillyHome Future</h1>
<section class="grid" id="metrics"></section>
<section class="split">
<div class="card">
<h2>Control</h2>
<label for="entitySearch">Suche</label>
<input id="entitySearch" placeholder="light., switch., sensor..." autocomplete="off">
<label for="actuator">Aktor</label>
<select id="actuator"></select>
<div class="row">
<div>
<label for="minConfidence">Min. Confidence</label>
<input id="minConfidence" type="number" min="0" max="1" step="0.01" value="0.82">
</div>
<div>
<label for="cooldown">Cooldown Sekunden</label>
<input id="cooldown" type="number" min="0" step="30" value="900">
</div>
</div>
<label><input id="manualBlock" type="checkbox" style="width:auto;margin-right:6px"> Manuell blockieren</label>
<div class="stages" id="stages"></div>
<button class="primary" id="saveControl">Control speichern</button>
<div class="status" id="controlStatus"></div>
</div>
<div class="card">
<h2>Learning Pattern</h2>
<div class="row">
<div>
<label for="trigger">Trigger</label>
<select id="trigger"></select>
</div>
<div>
<label for="triggerState">Trigger State</label>
<input id="triggerState" placeholder="on, off, open...">
</div>
</div>
<div class="row">
<div>
<label for="targetState">Zielzustand</label>
<input id="targetState" value="on">
</div>
<div>
<label for="patternConfidence">Confidence</label>
<input id="patternConfidence" type="number" min="0" max="1" step="0.01" value="0.9">
</div>
</div>
<button class="primary" id="addPattern">Pattern anlegen</button>
<div class="status" id="patternStatus"></div>
<h2>Aktuelles Profil</h2>
<pre id="profile">Lade...</pre>
</div>
</section>
<h2>Audit</h2>
<pre id="audit">Lade...</pre>
</main>
<script>
const stages = ['observe', 'dry_run', 'active', 'blocked'];
const state = { entities: [], control: {}, learning: {}, selectedStage: 'observe' };
function optionText(entity) {
const name = entity.friendly_name ? ` - ${entity.friendly_name}` : '';
const current = entity.state == null ? '' : ` (${entity.state})`;
return `${entity.entity_id}${current}${name}`;
}
function setStatus(id, text) {
document.getElementById(id).textContent = text;
if (text) setTimeout(() => document.getElementById(id).textContent = '', 4000);
}
function renderStages() {
document.getElementById('stages').innerHTML = stages.map(stage =>
`<button type="button" data-stage="${stage}" class="${stage === state.selectedStage ? 'active' : ''}">${stage}</button>`
).join('');
document.querySelectorAll('[data-stage]').forEach(button => {
button.onclick = () => { state.selectedStage = button.dataset.stage; renderStages(); };
});
}
function renderEntities() {
const actuator = document.getElementById('actuator');
const trigger = document.getElementById('trigger');
const query = document.getElementById('entitySearch').value.toLowerCase();
const shown = state.entities.filter(entity =>
!query || optionText(entity).toLowerCase().includes(query)
);
const actuators = shown.filter(entity => ['light', 'switch', 'fan', 'cover', 'humidifier'].includes(entity.domain));
actuator.innerHTML = actuators.map(entity => `<option value="${entity.entity_id}">${optionText(entity)}</option>`).join('');
trigger.innerHTML = shown.map(entity => `<option value="${entity.entity_id}">${optionText(entity)}</option>`).join('');
renderProfile();
}
function renderProfile() {
const id = document.getElementById('actuator').value;
const profile = {
control: state.control.profiles?.[id] || null,
learning: state.learning.profiles?.[id] || null,
};
if (profile.control) {
state.selectedStage = profile.control.stage;
document.getElementById('minConfidence').value = profile.control.min_confidence;
document.getElementById('cooldown').value = profile.control.cooldown_seconds;
document.getElementById('manualBlock').checked = profile.control.manual_block;
renderStages();
}
document.getElementById('profile').textContent = JSON.stringify(profile, null, 2);
}
async function loadDashboard() {
const response = await fetch('/v2/dashboard');
const data = await response.json();
const [dashResponse, entitiesResponse, controlResponse, learningResponse] = await Promise.all([
fetch('/v2/dashboard'),
fetch('/v2/entities?domain=light,switch,fan,cover,humidifier,binary_sensor,sensor'),
fetch('/v2/control'),
fetch('/v2/learning'),
]);
const data = await dashResponse.json();
state.entities = await entitiesResponse.json();
state.control = await controlResponse.json();
state.learning = await learningResponse.json();
const metrics = [
['WebSocket', data.websocket_status],
['Entities', data.entity_count],
['Actuators', data.actuator_count],
['Learning', data.learning_profiles],
['Control', data.control_profiles],
['Rooms', data.rooms.length],
@@ -260,8 +536,45 @@ def _dashboard_html() -> str:
document.getElementById('metrics').innerHTML = metrics.map(([label, value]) =>
`<div class="card"><div class="label">${label}</div><div class="value">${value}</div></div>`
).join('');
renderEntities();
document.getElementById('audit').textContent = JSON.stringify(data.audit, null, 2);
}
document.getElementById('entitySearch').oninput = renderEntities;
document.getElementById('actuator').onchange = renderProfile;
document.getElementById('saveControl').onclick = async () => {
const id = document.getElementById('actuator').value;
const response = await fetch(`/v2/control/${encodeURIComponent(id)}/stage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
stage: state.selectedStage,
min_confidence: Number(document.getElementById('minConfidence').value),
manual_block: document.getElementById('manualBlock').checked,
cooldown_seconds: Number(document.getElementById('cooldown').value),
}),
});
if (!response.ok) throw new Error(await response.text());
setStatus('controlStatus', 'Gespeichert');
await loadDashboard();
};
document.getElementById('addPattern').onclick = async () => {
const id = document.getElementById('actuator').value;
const response = await fetch(`/v2/learning/${encodeURIComponent(id)}/patterns`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
trigger_entity_id: document.getElementById('trigger').value,
trigger_state: document.getElementById('triggerState').value || null,
target_state: document.getElementById('targetState').value,
confidence: Number(document.getElementById('patternConfidence').value),
}),
});
if (!response.ok) throw new Error(await response.text());
setStatus('patternStatus', 'Pattern angelegt');
await loadDashboard();
};
renderStages();
loadDashboard();
setInterval(loadDashboard, 5000);
</script>

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "sillyhome-future"
version = "2.0.0-alpha.4"
version = "2.0.0-alpha.8"
description = "SillyHome v2 event-core prototype"
requires-python = ">=3.11"
dependencies = [

View File

@@ -9,7 +9,7 @@ def test_addon_config_declares_future_addon() -> None:
config = yaml.safe_load(Path("addon/config.yaml").read_text(encoding="utf-8"))
assert config["slug"] == "sillyhome_future"
assert config["version"] == "2.0.0-alpha.4"
assert config["version"] == "2.0.0-alpha.8"
assert config["ingress"] is True
assert config["ingress_port"] == 8099
assert config["homeassistant_api"] is True

View File

@@ -2,20 +2,80 @@ from __future__ import annotations
from fastapi.testclient import TestClient
from app.main import app, stores
import app.main as main
from app.core.event_core import EventCore
from app.core.models import EntityState, RuntimeState
from app.core.stores import FutureStores
def test_health_and_backup_roundtrip(tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.setenv("SILLYHOME_FUTURE_STORE", str(tmp_path))
client = TestClient(app)
test_stores = FutureStores(tmp_path)
monkeypatch.setattr(main, "stores", test_stores)
monkeypatch.setattr(main, "event_core", EventCore(test_stores))
client = TestClient(main.app)
health = client.get("/health")
backup = client.get("/v2/backup/export")
restore = client.post("/v2/backup/restore", json=backup.json())
assert stores is not None
assert health.status_code == 200
assert health.json()["version"] == "2.0.0-alpha.4"
assert health.json()["version"] == "2.0.0-alpha.8"
assert backup.status_code == 200
assert restore.status_code == 200
assert restore.json() == {"status": "restored"}
def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
test_stores = FutureStores(tmp_path)
test_stores.save_runtime(
RuntimeState(
entities={
"light.storage": EntityState(
entity_id="light.storage",
domain="light",
state="off",
),
"binary_sensor.storage_door": EntityState(
entity_id="binary_sensor.storage_door",
domain="binary_sensor",
state="off",
),
}
)
)
monkeypatch.setattr(main, "stores", test_stores)
monkeypatch.setattr(main, "event_core", EventCore(test_stores))
client = TestClient(main.app)
entities = client.get("/v2/entities?domain=light,binary_sensor")
control = client.post(
"/v2/control/light.storage/stage",
json={
"stage": "dry_run",
"min_confidence": 0.75,
"manual_block": False,
"cooldown_seconds": 120,
},
)
learning = client.post(
"/v2/learning/light.storage/patterns",
json={
"trigger_entity_id": "binary_sensor.storage_door",
"trigger_state": "on",
"target_state": "on",
"confidence": 0.91,
},
)
dashboard = client.get("/v2/dashboard")
assert entities.status_code == 200
assert [item["entity_id"] for item in entities.json()] == [
"binary_sensor.storage_door",
"light.storage",
]
assert control.status_code == 200
assert control.json()["stage"] == "dry_run"
assert learning.status_code == 200
assert learning.json()["patterns"][0]["trigger_entity_id"] == "binary_sensor.storage_door"
assert dashboard.status_code == 200
assert dashboard.json()["actuator_count"] == 1