2 Commits

Author SHA1 Message Date
07e3e96c30 Add Future parity feature blocks 2026-06-18 16:20:30 +02:00
e4b860571a Enable watchdog and localize dashboard 2026-06-18 14:26:26 +02:00
7 changed files with 528 additions and 26 deletions

View File

@@ -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.11
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,12 +1,12 @@
name: SillyHome Future
version: "2.0.0-alpha.9"
version: "2.0.0-alpha.11"
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

View File

@@ -21,6 +21,19 @@ class SafetyStage(StrEnum):
BLOCKED = "blocked"
class ProposalStatus(StrEnum):
DRAFT = "draft"
APPROVED = "approved"
REJECTED = "rejected"
class JobStatus(StrEnum):
QUEUED = "queued"
RUNNING = "running"
SUCCEEDED = "succeeded"
FAILED = "failed"
class EntityState(BaseModel):
entity_id: str = Field(pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$")
domain: str
@@ -89,6 +102,51 @@ class SceneProfile(BaseModel):
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
class AutomationProposal(BaseModel):
proposal_id: str
name: str
trigger_entity_id: str
trigger_state: str | None = None
actuator_entity_id: str
target_state: str
status: ProposalStatus = ProposalStatus.DRAFT
revision: int = 1
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
decided_at: datetime | None = None
class ModelRecord(BaseModel):
model_id: str
actuator_entity_id: str
pattern_count: int
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
trained_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class JobQueueItem(BaseModel):
job_id: str
kind: str
status: JobStatus = JobStatus.QUEUED
message: str = ""
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
finished_at: datetime | None = None
class SensorWeightOverride(BaseModel):
actuator_entity_id: str
sensor_weights: dict[str, float] = Field(default_factory=dict)
note: str | None = Field(default=None, max_length=500)
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class HistoryAnalysis(BaseModel):
entity_id: str
samples: int
last_state: str | None = None
changed_at: datetime | None = None
recommendation: str
class Decision(BaseModel):
actuator_entity_id: str
target_state: str | None = None
@@ -121,6 +179,10 @@ class LearningState(BaseModel):
profiles: dict[str, LearningProfile] = Field(default_factory=dict)
rooms: dict[str, RoomProfile] = Field(default_factory=dict)
scenes: dict[str, SceneProfile] = Field(default_factory=dict)
automation_proposals: dict[str, AutomationProposal] = Field(default_factory=dict)
models: dict[str, ModelRecord] = Field(default_factory=dict)
jobs: dict[str, JobQueueItem] = Field(default_factory=dict)
weight_overrides: dict[str, SensorWeightOverride] = Field(default_factory=dict)
class ControlState(BaseModel):

View File

@@ -4,6 +4,7 @@ import asyncio
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager, suppress
from datetime import datetime, timezone
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
@@ -15,16 +16,23 @@ from app.core.ha_client import FutureHaClient, HaClientConfig, service_for_state
from app.core.handoff import HandoffMatrix
from app.core.models import (
AuditEvent,
AutomationProposal,
BackupBundle,
BehaviorPatternV2,
ControlProfile,
ControlState,
EntityState,
HistoryAnalysis,
HandoffMode,
JobQueueItem,
JobStatus,
LearningProfile,
LearningState,
ModelRecord,
ProposalStatus,
RuntimeState,
SafetyStage,
SensorWeightOverride,
StateEvent,
)
from app.core.stores import FutureStores
@@ -70,6 +78,43 @@ 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
class AutomationProposalRequest(BaseModel):
name: str = Field(max_length=120)
trigger_entity_id: str
trigger_state: str | None = None
actuator_entity_id: str
target_state: str
class HistoryAnalysisRequest(BaseModel):
entity_ids: list[str] = Field(default_factory=list)
class TrainModelRequest(BaseModel):
actuator_entity_id: str | None = None
class WeightOverrideRequest(BaseModel):
sensor_weights: dict[str, float] = Field(default_factory=dict)
note: str | None = Field(default=None, max_length=500)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
global ha_client
@@ -90,7 +135,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.11",
lifespan=lifespan,
)
@@ -143,12 +188,164 @@ def dashboard_data() -> dict[str, object]:
"global_enabled": control.global_enabled,
"learning_profiles": len(learning.profiles),
"control_profiles": len(control.profiles),
"automation_proposals": len(learning.automation_proposals),
"models": len(learning.models),
"jobs": len(learning.jobs),
"rooms": list(learning.rooms.values()),
"scenes": list(learning.scenes.values()),
"audit": latest_audit,
}
@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",
"Automation-Proposals",
"YAML-Export",
"History-Analyse",
"lokales Modelltraining",
"Sensor-Gewichte",
"Job-Queue",
],
"next_to_expand": ["echte Langzeit-History aus HA", "fortgeschrittene Modellbewertung"],
}
@app.post("/v2/automations/proposals", response_model=AutomationProposal)
def create_automation_proposal(payload: AutomationProposalRequest) -> AutomationProposal:
state = stores.learning()
proposal_id = f"proposal-{len(state.automation_proposals) + 1}"
proposal = AutomationProposal(
proposal_id=proposal_id,
name=payload.name,
trigger_entity_id=payload.trigger_entity_id,
trigger_state=payload.trigger_state,
actuator_entity_id=payload.actuator_entity_id,
target_state=payload.target_state,
)
state.automation_proposals[proposal_id] = proposal
stores.save_learning(state)
_append_job("automation_proposal", f"Automation-Vorschlag {proposal_id} angelegt.")
return proposal
@app.get("/v2/automations/proposals", response_model=list[AutomationProposal])
def list_automation_proposals() -> list[AutomationProposal]:
return list(stores.learning().automation_proposals.values())
@app.post("/v2/automations/proposals/{proposal_id}/approve", response_model=AutomationProposal)
def approve_automation_proposal(proposal_id: str) -> AutomationProposal:
return _decide_automation_proposal(proposal_id, ProposalStatus.APPROVED)
@app.post("/v2/automations/proposals/{proposal_id}/reject", response_model=AutomationProposal)
def reject_automation_proposal(proposal_id: str) -> AutomationProposal:
return _decide_automation_proposal(proposal_id, ProposalStatus.REJECTED)
@app.get("/v2/automations/proposals/{proposal_id}/yaml")
def automation_proposal_yaml(proposal_id: str) -> str:
proposal = stores.learning().automation_proposals[proposal_id]
return "\n".join(
[
f"alias: {proposal.name}",
"trigger:",
" - platform: state",
f" entity_id: {proposal.trigger_entity_id}",
f" to: {proposal.trigger_state or ''}",
"action:",
" - service: homeassistant.turn_on",
" target:",
f" entity_id: {proposal.actuator_entity_id}",
" data:",
f" target_state: {proposal.target_state}",
"mode: single",
"",
]
)
@app.post("/v2/history/analyze", response_model=list[HistoryAnalysis])
def analyze_history(request: HistoryAnalysisRequest) -> list[HistoryAnalysis]:
runtime = stores.runtime()
ids = request.entity_ids or list(runtime.entities)[:50]
result: list[HistoryAnalysis] = []
for entity_id in ids:
entity = runtime.entities.get(entity_id)
matching_audit = [item for item in runtime.audit if item.entity_id == entity_id]
result.append(
HistoryAnalysis(
entity_id=entity_id,
samples=max(1, len(matching_audit)),
last_state=entity.state if entity else None,
changed_at=entity.changed_at if entity else None,
recommendation=(
"Als Trigger geeignet."
if entity is not None and entity.domain in {"binary_sensor", "sensor"}
else "Als Aktor oder Kontext pruefen."
),
)
)
_append_job("history_analysis", f"History-Analyse fuer {len(result)} Entities erstellt.")
return result
@app.post("/v2/models/train", response_model=list[ModelRecord])
def train_models(request: TrainModelRequest) -> list[ModelRecord]:
state = stores.learning()
actuator_ids = [request.actuator_entity_id] if request.actuator_entity_id else list(state.profiles)
trained: list[ModelRecord] = []
for actuator_id in actuator_ids:
if actuator_id is None:
continue
profile = state.profiles.get(actuator_id)
pattern_count = len(profile.patterns) if profile else 0
confidence = (
sum(pattern.confidence for pattern in profile.patterns) / pattern_count
if profile and pattern_count
else 0.0
)
model = ModelRecord(
model_id=f"model-{actuator_id}-{len(state.models) + 1}",
actuator_entity_id=actuator_id,
pattern_count=pattern_count,
confidence=confidence,
)
state.models[model.model_id] = model
trained.append(model)
stores.save_learning(state)
_append_job("model_training", f"{len(trained)} lokale Modelle trainiert.")
return trained
@app.get("/v2/models", response_model=list[ModelRecord])
def list_models() -> list[ModelRecord]:
return list(stores.learning().models.values())
@app.get("/v2/jobs", response_model=list[JobQueueItem])
def list_jobs() -> list[JobQueueItem]:
return list(stores.learning().jobs.values())[-100:]
@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 +400,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 +541,118 @@ 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/control/{actuator_entity_id}/weights", response_model=SensorWeightOverride)
def set_weight_override(
actuator_entity_id: str,
payload: WeightOverrideRequest,
) -> SensorWeightOverride:
state = stores.learning()
override = SensorWeightOverride(
actuator_entity_id=actuator_entity_id,
sensor_weights=payload.sensor_weights,
note=payload.note,
)
state.weight_overrides[actuator_entity_id] = override
stores.save_learning(state)
_append_audit("weights", actuator_entity_id, "Sensor-Gewichte aktualisiert.")
return override
@app.get("/v2/control/{actuator_entity_id}/weights", response_model=SensorWeightOverride)
def get_weight_override(actuator_entity_id: str) -> SensorWeightOverride:
return stores.learning().weight_overrides.get(
actuator_entity_id,
SensorWeightOverride(actuator_entity_id=actuator_entity_id),
)
@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()
@@ -503,6 +851,39 @@ def _append_audit(kind: str, entity_id: str | None, message: str) -> None:
stores.save_runtime(runtime)
def _append_job(kind: str, message: str, status: JobStatus = JobStatus.SUCCEEDED) -> JobQueueItem:
state = stores.learning()
job = JobQueueItem(
job_id=f"job-{len(state.jobs) + 1}",
kind=kind,
status=status,
message=message,
finished_at=datetime.now(timezone.utc) if status is JobStatus.SUCCEEDED else None,
)
state.jobs[job.job_id] = job
stores.save_learning(state)
return job
def _decide_automation_proposal(
proposal_id: str,
status: ProposalStatus,
) -> AutomationProposal:
state = stores.learning()
proposal = state.automation_proposals[proposal_id]
updated = proposal.model_copy(
update={
"status": status,
"revision": proposal.revision + 1,
"decided_at": datetime.now(timezone.utc),
}
)
state.automation_proposals[proposal_id] = updated
stores.save_learning(state)
_append_job("automation_proposal", f"Automation-Vorschlag {proposal_id}: {status}.")
return updated
def _execute_ha_decision(decision: object) -> bool:
if ha_client is None or not hasattr(decision, "actuator_entity_id"):
return False
@@ -554,37 +935,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 +975,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 +1012,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 +1061,16 @@ 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],
['Vorschlaege', data.automation_proposals],
['Modelle', data.models],
['Jobs', data.jobs],
['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 +1086,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;

View File

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

View File

@@ -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.11"
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:

View File

@@ -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.11"
assert backup.status_code == 200
assert restore.status_code == 200
assert restore.json() == {"status": "restored"}
@@ -77,6 +77,32 @@ 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")
proposal = client.post(
"/v2/automations/proposals",
json={
"name": "Storage light",
"trigger_entity_id": "binary_sensor.storage_door",
"trigger_state": "on",
"actuator_entity_id": "light.storage",
"target_state": "on",
},
)
proposal_yaml = client.get("/v2/automations/proposals/proposal-1/yaml")
approved = client.post("/v2/automations/proposals/proposal-1/approve")
weights = client.post(
"/v2/control/light.storage/weights",
json={"sensor_weights": {"binary_sensor.storage_door": 1.0}, "note": "door"},
)
history = client.post(
"/v2/history/analyze",
json={"entity_ids": ["binary_sensor.storage_door"]},
)
models = client.post("/v2/models/train", json={"actuator_entity_id": "light.storage"})
jobs = client.get("/v2/jobs")
dashboard = client.get("/v2/dashboard")
assert entities.status_code == 200
@@ -96,5 +122,27 @@ 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 "Job-Queue" in parity.json()["implemented"]
assert anomalies.status_code == 200
assert proposal.status_code == 200
assert proposal.json()["status"] == "draft"
assert proposal_yaml.status_code == 200
assert "alias: Storage light" in proposal_yaml.text
assert approved.status_code == 200
assert approved.json()["status"] == "approved"
assert weights.status_code == 200
assert weights.json()["sensor_weights"]["binary_sensor.storage_door"] == 1.0
assert history.status_code == 200
assert history.json()[0]["entity_id"] == "binary_sensor.storage_door"
assert models.status_code == 200
assert models.json()[0]["actuator_entity_id"] == "light.storage"
assert jobs.status_code == 200
assert jobs.json()
assert dashboard.status_code == 200
assert dashboard.json()["actuator_count"] == 1
assert dashboard.json()["automation_proposals"] == 1