Enable watchdog and localize dashboard
This commit is contained in:
@@ -2,7 +2,7 @@ FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG SILLYHOME_FUTURE_REF=v2.0.0-alpha.9
|
||||
ARG SILLYHOME_FUTURE_REF=v2.0.0-alpha.10
|
||||
RUN python -m pip install --no-cache-dir \
|
||||
"http://192.168.6.31:3000/Otto/sillyhome-future/archive/${SILLYHOME_FUTURE_REF}.tar.gz"
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
name: SillyHome Future
|
||||
version: "2.0.0-alpha.9"
|
||||
version: "2.0.0-alpha.10"
|
||||
slug: sillyhome_future
|
||||
description: Event-first SillyHome v2 test controller
|
||||
url: http://192.168.6.31:3000/Otto/sillyhome-future
|
||||
arch:
|
||||
- amd64
|
||||
startup: application
|
||||
boot: manual
|
||||
boot: auto
|
||||
watchdog: http://[HOST]:[PORT:8099]/health
|
||||
init: false
|
||||
ingress: true
|
||||
|
||||
219
app/main.py
219
app/main.py
@@ -70,6 +70,22 @@ class ActiveReadiness(BaseModel):
|
||||
dry_run_failures: int
|
||||
|
||||
|
||||
class FeedbackRequest(BaseModel):
|
||||
kind: str = Field(default="correct", max_length=40)
|
||||
expected_state: str | None = Field(default=None, max_length=100)
|
||||
note: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class ActuatorSummary(BaseModel):
|
||||
actuator_entity_id: str
|
||||
stage: SafetyStage
|
||||
handoff_mode: HandoffMode
|
||||
active_ready: bool
|
||||
pattern_count: int
|
||||
feedback_positive: int
|
||||
feedback_negative: int
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
global ha_client
|
||||
@@ -90,7 +106,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.9",
|
||||
version="2.0.0-alpha.10",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@@ -149,6 +165,37 @@ def dashboard_data() -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
@app.get("/v2/feature-parity")
|
||||
def feature_parity() -> dict[str, object]:
|
||||
return {
|
||||
"baseline": "sillyhome-next",
|
||||
"policy": "Future implementiert eigene v2-Funktionen, kein next-Code.",
|
||||
"implemented": [
|
||||
"HA REST/WebSocket",
|
||||
"Service-Ausfuehrung",
|
||||
"Dashboard",
|
||||
"Aktor-Control",
|
||||
"Lernmuster",
|
||||
"Dry-run",
|
||||
"Safety-Gates",
|
||||
"Feedback",
|
||||
"Backup/Restore",
|
||||
"Raeume/Szenen",
|
||||
"Not-Aus",
|
||||
"Simulation",
|
||||
"Audit",
|
||||
"Health",
|
||||
],
|
||||
"next_to_expand": [
|
||||
"automations-proposals",
|
||||
"history-analysis",
|
||||
"model-training",
|
||||
"weight-overrides",
|
||||
"job-queue",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@app.post("/v2/events/state", response_model=list[AuditEvent])
|
||||
def ingest_state_event(event: StateEvent) -> list[AuditEvent]:
|
||||
return event_core.process_state_event(event, execute=_execute_ha_decision)
|
||||
@@ -203,6 +250,45 @@ def get_control() -> ControlState:
|
||||
return stores.control()
|
||||
|
||||
|
||||
@app.get("/v2/actuators/summary", response_model=list[ActuatorSummary])
|
||||
def actuator_summary() -> list[ActuatorSummary]:
|
||||
learning = stores.learning()
|
||||
control = stores.control()
|
||||
ids = sorted(set(learning.profiles) | set(control.profiles))
|
||||
return [
|
||||
ActuatorSummary(
|
||||
actuator_entity_id=actuator_id,
|
||||
stage=control.profiles.get(
|
||||
actuator_id,
|
||||
ControlProfile(actuator_entity_id=actuator_id),
|
||||
).stage,
|
||||
handoff_mode=control.profiles.get(
|
||||
actuator_id,
|
||||
ControlProfile(actuator_entity_id=actuator_id),
|
||||
).handoff_mode,
|
||||
active_ready=control.profiles.get(
|
||||
actuator_id,
|
||||
ControlProfile(actuator_entity_id=actuator_id),
|
||||
).active_ready,
|
||||
pattern_count=len(
|
||||
learning.profiles.get(
|
||||
actuator_id,
|
||||
LearningProfile(actuator_entity_id=actuator_id),
|
||||
).patterns
|
||||
),
|
||||
feedback_positive=learning.profiles.get(
|
||||
actuator_id,
|
||||
LearningProfile(actuator_entity_id=actuator_id),
|
||||
).feedback_positive,
|
||||
feedback_negative=learning.profiles.get(
|
||||
actuator_id,
|
||||
LearningProfile(actuator_entity_id=actuator_id),
|
||||
).feedback_negative,
|
||||
)
|
||||
for actuator_id in ids
|
||||
]
|
||||
|
||||
|
||||
@app.post("/v2/control/global", response_model=ControlState)
|
||||
def set_global_control(update: GlobalControlUpdate) -> ControlState:
|
||||
state = stores.control().model_copy(update={"global_enabled": update.enabled})
|
||||
@@ -305,6 +391,93 @@ def create_learning_pattern(
|
||||
return updated
|
||||
|
||||
|
||||
@app.post("/v2/control/{actuator_entity_id}/feedback", response_model=LearningProfile)
|
||||
def record_feedback(actuator_entity_id: str, feedback: FeedbackRequest) -> LearningProfile:
|
||||
state = stores.learning()
|
||||
profile = state.profiles.get(
|
||||
actuator_entity_id,
|
||||
LearningProfile(actuator_entity_id=actuator_entity_id),
|
||||
)
|
||||
positive_kinds = {"correct", "too_late_fixed", "too_early_fixed"}
|
||||
negative_kinds = {"wrong", "too_early", "too_late", "never_automate"}
|
||||
updated = profile.model_copy(
|
||||
update={
|
||||
"feedback_positive": profile.feedback_positive
|
||||
+ int(feedback.kind in positive_kinds),
|
||||
"feedback_negative": profile.feedback_negative
|
||||
+ int(feedback.kind in negative_kinds),
|
||||
}
|
||||
)
|
||||
state.profiles[actuator_entity_id] = updated
|
||||
stores.save_learning(state)
|
||||
if feedback.kind == "never_automate":
|
||||
control = stores.control()
|
||||
control.profiles[actuator_entity_id] = control.profiles.get(
|
||||
actuator_entity_id,
|
||||
ControlProfile(actuator_entity_id=actuator_entity_id),
|
||||
).model_copy(update={"manual_block": True, "stage": SafetyStage.BLOCKED})
|
||||
stores.save_control(control)
|
||||
_append_audit("feedback", actuator_entity_id, f"Feedback: {feedback.kind}")
|
||||
return updated
|
||||
|
||||
|
||||
@app.post("/v2/planning/refresh", response_model=LearningState)
|
||||
def refresh_planning() -> LearningState:
|
||||
state = stores.learning()
|
||||
runtime = stores.runtime()
|
||||
rooms = dict(state.rooms)
|
||||
scenes = dict(state.scenes)
|
||||
for entity in runtime.entities.values():
|
||||
room_name = entity.area_name
|
||||
if not room_name:
|
||||
continue
|
||||
room_id = room_name.lower().replace(" ", "_")
|
||||
room = rooms.get(room_id)
|
||||
actuator_ids = []
|
||||
context_ids = []
|
||||
if entity.domain in {"light", "switch", "fan", "cover", "humidifier"}:
|
||||
actuator_ids.append(entity.entity_id)
|
||||
else:
|
||||
context_ids.append(entity.entity_id)
|
||||
if room is None:
|
||||
from app.core.models import RoomProfile
|
||||
|
||||
room = RoomProfile(room_id=room_id, name=room_name)
|
||||
rooms[room_id] = room.model_copy(
|
||||
update={
|
||||
"actuator_entity_ids": sorted(
|
||||
set(room.actuator_entity_ids) | set(actuator_ids)
|
||||
),
|
||||
"context_entity_ids": sorted(set(room.context_entity_ids) | set(context_ids)),
|
||||
}
|
||||
)
|
||||
state = state.model_copy(update={"rooms": rooms, "scenes": scenes})
|
||||
stores.save_learning(state)
|
||||
_append_audit("planning", None, "Planung aktualisiert.")
|
||||
return state
|
||||
|
||||
|
||||
@app.get("/v2/anomalies")
|
||||
def anomalies() -> list[dict[str, object]]:
|
||||
runtime = stores.runtime()
|
||||
result: list[dict[str, object]] = []
|
||||
unavailable = [
|
||||
entity.entity_id
|
||||
for entity in runtime.entities.values()
|
||||
if entity.state in {"unavailable", "unknown"}
|
||||
][:100]
|
||||
if unavailable:
|
||||
result.append(
|
||||
{
|
||||
"kind": "unavailable_entities",
|
||||
"severity": "warning",
|
||||
"count": len(unavailable),
|
||||
"entities": unavailable,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/v2/simulate")
|
||||
def simulate_decision(request: SimulationRequest) -> dict[str, object]:
|
||||
runtime = stores.runtime()
|
||||
@@ -554,37 +727,37 @@ def _dashboard_html() -> str:
|
||||
<section class="grid" id="metrics"></section>
|
||||
<section class="split">
|
||||
<div class="card">
|
||||
<h2>Control</h2>
|
||||
<h2>Steuerung</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>
|
||||
<label for="minConfidence">Mindest-Sicherheit</label>
|
||||
<input id="minConfidence" type="number" min="0" max="1" step="0.01" value="0.82">
|
||||
</div>
|
||||
<div>
|
||||
<label for="cooldown">Cooldown Sekunden</label>
|
||||
<label for="cooldown">Sperrzeit in 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>
|
||||
<button class="primary" id="saveControl">Steuerung speichern</button>
|
||||
<button id="globalToggle">Globaler Not-Aus</button>
|
||||
<div class="status" id="readiness">Ready-Status wird geladen...</div>
|
||||
<div class="status" id="controlStatus"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Learning Pattern</h2>
|
||||
<h2>Lernmuster</h2>
|
||||
<div class="row">
|
||||
<div>
|
||||
<label for="trigger">Trigger</label>
|
||||
<select id="trigger"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="triggerState">Trigger State</label>
|
||||
<label for="triggerState">Trigger-Zustand</label>
|
||||
<input id="triggerState" placeholder="on, off, open...">
|
||||
</div>
|
||||
</div>
|
||||
@@ -594,22 +767,28 @@ def _dashboard_html() -> str:
|
||||
<input id="targetState" value="on">
|
||||
</div>
|
||||
<div>
|
||||
<label for="patternConfidence">Confidence</label>
|
||||
<label for="patternConfidence">Sicherheit</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>
|
||||
<button id="simulatePattern">Pattern simulieren</button>
|
||||
<button class="primary" id="addPattern">Lernmuster anlegen</button>
|
||||
<button id="simulatePattern">Lernmuster simulieren</button>
|
||||
<div class="status" id="patternStatus"></div>
|
||||
<h2>Aktuelles Profil</h2>
|
||||
<pre id="profile">Lade...</pre>
|
||||
</div>
|
||||
</section>
|
||||
<h2>Audit</h2>
|
||||
<h2>Prüfprotokoll</h2>
|
||||
<pre id="audit">Lade...</pre>
|
||||
</main>
|
||||
<script>
|
||||
const stages = ['observe', 'dry_run', 'active', 'blocked'];
|
||||
const stageLabels = {
|
||||
observe: 'Beobachten',
|
||||
dry_run: 'Testlauf',
|
||||
active: 'Aktiv',
|
||||
blocked: 'Gesperrt',
|
||||
};
|
||||
const state = { entities: [], control: {}, learning: {}, selectedStage: 'observe' };
|
||||
|
||||
function optionText(entity) {
|
||||
@@ -625,7 +804,7 @@ def _dashboard_html() -> str:
|
||||
|
||||
function renderStages() {
|
||||
document.getElementById('stages').innerHTML = stages.map(stage =>
|
||||
`<button type="button" data-stage="${stage}" class="${stage === state.selectedStage ? 'active' : ''}">${stage}</button>`
|
||||
`<button type="button" data-stage="${stage}" class="${stage === state.selectedStage ? 'active' : ''}">${stageLabels[stage]}</button>`
|
||||
).join('');
|
||||
document.querySelectorAll('[data-stage]').forEach(button => {
|
||||
button.onclick = () => { state.selectedStage = button.dataset.stage; renderStages(); };
|
||||
@@ -674,13 +853,13 @@ def _dashboard_html() -> str:
|
||||
state.learning = await learningResponse.json();
|
||||
const metrics = [
|
||||
['WebSocket', data.websocket_status],
|
||||
['Entities', data.entity_count],
|
||||
['Actuators', data.actuator_count],
|
||||
['Global', data.global_enabled ? 'on' : 'off'],
|
||||
['Learning', data.learning_profiles],
|
||||
['Control', data.control_profiles],
|
||||
['Rooms', data.rooms.length],
|
||||
['Scenes', data.scenes.length],
|
||||
['Entitäten', data.entity_count],
|
||||
['Aktoren', data.actuator_count],
|
||||
['Not-Aus', data.global_enabled ? 'frei' : 'aktiv'],
|
||||
['Lernen', data.learning_profiles],
|
||||
['Steuerung', data.control_profiles],
|
||||
['Räume', data.rooms.length],
|
||||
['Szenen', data.scenes.length],
|
||||
];
|
||||
document.getElementById('metrics').innerHTML = metrics.map(([label, value]) =>
|
||||
`<div class="card"><div class="label">${label}</div><div class="value">${value}</div></div>`
|
||||
@@ -696,7 +875,7 @@ def _dashboard_html() -> str:
|
||||
const response = await fetch(`/v2/control/${encodeURIComponent(id)}/readiness`);
|
||||
const data = await response.json();
|
||||
document.getElementById('readiness').textContent =
|
||||
`${data.ready ? 'ready for active' : 'nicht active-ready'} - ${data.reason}`;
|
||||
`${data.ready ? 'bereit fuer Aktiv' : 'nicht bereit fuer Aktiv'} - ${data.reason}`;
|
||||
}
|
||||
|
||||
document.getElementById('entitySearch').oninput = renderEntities;
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "sillyhome-future"
|
||||
version = "2.0.0-alpha.9"
|
||||
version = "2.0.0-alpha.10"
|
||||
description = "SillyHome v2 event-core prototype"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -9,10 +9,12 @@ 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.9"
|
||||
assert config["version"] == "2.0.0-alpha.10"
|
||||
assert config["ingress"] is True
|
||||
assert config["ingress_port"] == 8099
|
||||
assert config["homeassistant_api"] is True
|
||||
assert config["boot"] == "auto"
|
||||
assert config["watchdog"] == "http://[HOST]:[PORT:8099]/health"
|
||||
|
||||
|
||||
def test_repository_points_to_gitea_repo() -> None:
|
||||
|
||||
@@ -19,7 +19,7 @@ def test_health_and_backup_roundtrip(tmp_path, monkeypatch) -> None: # type: ig
|
||||
restore = client.post("/v2/backup/restore", json=backup.json())
|
||||
|
||||
assert health.status_code == 200
|
||||
assert health.json()["version"] == "2.0.0-alpha.9"
|
||||
assert health.json()["version"] == "2.0.0-alpha.10"
|
||||
assert backup.status_code == 200
|
||||
assert restore.status_code == 200
|
||||
assert restore.json() == {"status": "restored"}
|
||||
@@ -77,6 +77,10 @@ def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None
|
||||
readiness = client.get("/v2/control/light.storage/readiness")
|
||||
global_control = client.post("/v2/control/global", json={"enabled": False})
|
||||
detailed_health = client.get("/v2/health")
|
||||
feedback = client.post("/v2/control/light.storage/feedback", json={"kind": "wrong"})
|
||||
summary = client.get("/v2/actuators/summary")
|
||||
parity = client.get("/v2/feature-parity")
|
||||
anomalies = client.get("/v2/anomalies")
|
||||
dashboard = client.get("/v2/dashboard")
|
||||
|
||||
assert entities.status_code == 200
|
||||
@@ -96,5 +100,12 @@ def test_dashboard_control_and_learning_endpoints(tmp_path, monkeypatch) -> None
|
||||
assert global_control.json()["global_enabled"] is False
|
||||
assert detailed_health.status_code == 200
|
||||
assert detailed_health.json()["global_enabled"] is False
|
||||
assert feedback.status_code == 200
|
||||
assert feedback.json()["feedback_negative"] == 1
|
||||
assert summary.status_code == 200
|
||||
assert summary.json()[0]["actuator_entity_id"] == "light.storage"
|
||||
assert parity.status_code == 200
|
||||
assert "Feedback" in parity.json()["implemented"]
|
||||
assert anomalies.status_code == 200
|
||||
assert dashboard.status_code == 200
|
||||
assert dashboard.json()["actuator_count"] == 1
|
||||
|
||||
Reference in New Issue
Block a user