Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4db4276b95 |
11
CHANGELOG.md
11
CHANGELOG.md
@@ -1,5 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## 0.7.21 - 2026-06-17
|
||||
- Dashboard-Ladepfad getrennt: beobachtete Geräte laden sofort über
|
||||
`/v1/actuators/summary`; Status, Discovery und Vorschläge laufen unabhängig
|
||||
nachgelagert und blockieren die Übersicht nicht mehr.
|
||||
- Systemstatus nutzt Timeouts und bleibt auch bei langsamem ML-/HA-Status
|
||||
bedienbar.
|
||||
- HA-Entity-Metadaten werden als JSON-Cache gespeichert und für Friendly Name,
|
||||
Raum und Gerät in schlanken Summaries wiederverwendet.
|
||||
- Anleitung, Gerätegruppen und manuelle Kontextauswahl sind aufklappbar und
|
||||
kompakter für Smartphone- und Desktopansichten.
|
||||
|
||||
## 0.7.20 - 2026-06-17
|
||||
- Dashboard-Übersicht ist kompatibel mit dem leichten Summary-Format und greift
|
||||
nicht mehr auf `record.behavior.status` aus dem Vollformat zu.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: SillyHome Next
|
||||
version: "0.7.20"
|
||||
version: "0.7.21"
|
||||
slug: sillyhome_next
|
||||
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
|
||||
url: http://192.168.6.31:3000/pino/sillyhome-next
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -7,6 +12,7 @@ from app.actuators.lifecycle import ActuatorReconciliationService
|
||||
from app.actuators.models import ActuatorRecord, ReconciliationState
|
||||
from app.actuators.store import ActuatorStore
|
||||
from app.behavior.engine import BehaviorEngine
|
||||
from app.config import Settings
|
||||
from app.dependencies import get_ha_reader
|
||||
from app.ha.discovery import DiscoveredEntity, EntityRole
|
||||
from app.ha.exceptions import HaClientError
|
||||
@@ -58,6 +64,9 @@ class ActuatorSuggestion(BaseModel):
|
||||
class ActuatorSummary(BaseModel):
|
||||
actuator_entity_id: str
|
||||
domain: str
|
||||
friendly_name: str | None = None
|
||||
area_name: str | None = None
|
||||
device_name: str | None = None
|
||||
enabled: bool
|
||||
behavior_mode: str
|
||||
behavior_status: str
|
||||
@@ -71,8 +80,18 @@ class ActuatorSummary(BaseModel):
|
||||
|
||||
|
||||
@router.get("/discovery", response_model=list[HaEntitySummary])
|
||||
def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaEntitySummary]:
|
||||
entities = {entity.entity_id: entity for entity in ha_reader.read_entities()}
|
||||
def discover_actuators(
|
||||
request: Request,
|
||||
refresh: bool = Query(default=False),
|
||||
ha_reader: HaReader = Depends(get_ha_reader),
|
||||
) -> list[HaEntitySummary]:
|
||||
cached_entities = [] if refresh else _load_cached_entities(request)
|
||||
if cached_entities:
|
||||
entities = {entity.entity_id: entity for entity in cached_entities}
|
||||
else:
|
||||
fresh_entities = list(ha_reader.read_entities())
|
||||
_save_cached_entities(request, fresh_entities)
|
||||
entities = {entity.entity_id: entity for entity in fresh_entities}
|
||||
discovered = ha_reader.discover()
|
||||
actuator_ids = _deduplicate_actuator_ids(
|
||||
[
|
||||
@@ -160,10 +179,26 @@ def context_options(
|
||||
|
||||
@router.get("/summary", response_model=list[ActuatorSummary])
|
||||
def list_configured_summary(request: Request) -> list[ActuatorSummary]:
|
||||
entity_map = {entity.entity_id: entity for entity in _load_cached_entities(request)}
|
||||
return [
|
||||
ActuatorSummary(
|
||||
actuator_entity_id=record.actuator_entity_id,
|
||||
domain=record.actuator_entity_id.split(".", 1)[0],
|
||||
friendly_name=(
|
||||
entity_map[record.actuator_entity_id].friendly_name
|
||||
if record.actuator_entity_id in entity_map
|
||||
else None
|
||||
),
|
||||
area_name=(
|
||||
entity_map[record.actuator_entity_id].area_name
|
||||
if record.actuator_entity_id in entity_map
|
||||
else None
|
||||
),
|
||||
device_name=(
|
||||
entity_map[record.actuator_entity_id].device_name
|
||||
if record.actuator_entity_id in entity_map
|
||||
else None
|
||||
),
|
||||
enabled=record.enabled,
|
||||
behavior_mode=record.behavior.mode.value,
|
||||
behavior_status=record.behavior.status.value,
|
||||
@@ -377,6 +412,45 @@ def _behavior(request: Request) -> BehaviorEngine:
|
||||
return engine
|
||||
|
||||
|
||||
def _entity_cache_path(request: Request) -> Path:
|
||||
store = getattr(request.app.state, "actuator_store", None)
|
||||
settings = getattr(request.app.state, "settings", None)
|
||||
if isinstance(store, ActuatorStore):
|
||||
base_dir = store._root
|
||||
elif isinstance(settings, Settings):
|
||||
base_dir = Path(settings.actuator_store).resolve().parent
|
||||
else:
|
||||
base_dir = Path(".").resolve()
|
||||
return Path(os.getenv("SILLYHOME_ENTITY_CACHE", base_dir / "ha_entity_cache.json"))
|
||||
|
||||
|
||||
def _load_cached_entities(request: Request) -> list[HaEntitySummary]:
|
||||
path = _entity_cache_path(request)
|
||||
if not path.exists():
|
||||
return []
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
raw_entities = payload.get("entities", [])
|
||||
return [HaEntitySummary.model_validate(entity) for entity in raw_entities]
|
||||
except (OSError, TypeError, ValueError):
|
||||
return []
|
||||
|
||||
|
||||
def _save_cached_entities(request: Request, entities: list[HaEntitySummary]) -> None:
|
||||
path = _entity_cache_path(request)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"entities": [entity.model_dump(mode="json") for entity in entities],
|
||||
}
|
||||
temporary = path.with_suffix(".json.tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(payload, ensure_ascii=True, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def _deduplicate_actuator_ids(
|
||||
discovered: list[tuple[str, str]],
|
||||
entities: dict[str, HaEntitySummary],
|
||||
|
||||
@@ -105,7 +105,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.20",
|
||||
version="0.7.21",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.state.settings = load_settings()
|
||||
|
||||
@@ -6,27 +6,40 @@
|
||||
<title>SillyHome Next</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #10151c; color: #eaf1f8; scroll-behavior:smooth; }
|
||||
body { margin: 0; font-size:16px; }
|
||||
header { padding: 22px; background: linear-gradient(135deg,#142b3a,#193f36); }
|
||||
body { margin: 0; font-size:15px; }
|
||||
header { padding: 18px 20px; background: linear-gradient(135deg,#142b3a,#193f36); }
|
||||
h1,h2,h3 { margin: 0 0 12px; }
|
||||
header p { margin: 5px 0; color: #c3d1dc; }
|
||||
main { display: grid; grid-template-columns: repeat(auto-fit,minmax(320px,1fr)); gap: 14px; padding: 14px; }
|
||||
section { background: #18212b; border: 1px solid #2d3a47; border-radius: 12px; padding: 16px; }
|
||||
main { display: grid; grid-template-columns: minmax(280px,.85fr) minmax(0,1.35fr); gap: 12px; padding: 12px; max-width:1380px; margin:0 auto; }
|
||||
section { background: #18212b; border: 1px solid #2d3a47; border-radius: 8px; padding: 12px; min-width:0; }
|
||||
section:target { outline:2px solid #66dfa9; outline-offset:2px; }
|
||||
.wide { grid-column: 1 / -1; }
|
||||
.quick-nav { position:sticky; top:0; z-index:10; display:flex; gap:8px; overflow-x:auto; padding:10px 14px; background:rgba(16,21,28,.94); border-bottom:1px solid #2d3a47; backdrop-filter:blur(8px); }
|
||||
.quick-nav a { flex:0 0 auto; padding:10px 12px; border-radius:999px; background:#22303c; border:1px solid #31404d; color:#eaf1f8; text-decoration:none; font-weight:700; font-size:.92rem; }
|
||||
.quick-nav a.primary { background:#23715b; }
|
||||
.steps { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; }
|
||||
.step { background:#111a23; border:1px solid #31404d; border-radius:10px; padding:14px; }
|
||||
details.collapsible > summary,
|
||||
.manual-context > summary,
|
||||
.group-panel > summary { cursor:pointer; font-weight:800; color:#eaf1f8; }
|
||||
details.collapsible > summary { list-style:none; display:flex; justify-content:space-between; gap:10px; }
|
||||
details.collapsible > summary::-webkit-details-marker,
|
||||
.manual-context > summary::-webkit-details-marker,
|
||||
.group-panel > summary::-webkit-details-marker { display:none; }
|
||||
details.collapsible > summary::after,
|
||||
.manual-context > summary::after,
|
||||
.group-panel > summary::after { content:"aufklappen"; color:#9fb0be; font-weight:600; font-size:.86rem; }
|
||||
details[open].collapsible > summary::after,
|
||||
.manual-context[open] > summary::after,
|
||||
.group-panel[open] > summary::after { content:"zuklappen"; }
|
||||
.steps { display:grid; grid-template-columns:repeat(auto-fit,minmax(190px,1fr)); gap:10px; margin-top:12px; }
|
||||
.step { background:#111a23; border:1px solid #31404d; border-radius:8px; padding:10px; }
|
||||
.step-number { display:inline-grid; place-items:center; width:28px; height:28px; border-radius:50%; background:#23715b; font-weight:700; margin-bottom:8px; }
|
||||
.step p { margin:5px 0; }
|
||||
.ok { color: #66dfa9; }
|
||||
.warn { color: #f3c969; }
|
||||
.bad { color: #ff8f8f; }
|
||||
label { display: block; margin: 9px 0 4px; color: #b9c9d6; }
|
||||
select,input,button { box-sizing: border-box; width: 100%; border-radius: 10px; border: 1px solid #3b4b5b; padding: 12px; background: #101820; color: #fff; font:inherit; }
|
||||
select[multiple] { min-height:190px; }
|
||||
select,input,button { box-sizing: border-box; width: 100%; border-radius: 8px; border: 1px solid #3b4b5b; padding: 10px; background: #101820; color: #fff; font:inherit; min-width:0; }
|
||||
select[multiple] { min-height:150px; }
|
||||
button { min-height:44px; margin-top: 10px; background: #23715b; border: 0; font-weight: 700; cursor: pointer; }
|
||||
button.secondary { background: #37495c; }
|
||||
button.danger { background: #7b3434; }
|
||||
@@ -39,18 +52,18 @@
|
||||
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
|
||||
.chip { padding:4px 8px; border-radius:999px; background:#22303c; border:1px solid #31404d; font-size:.85rem; }
|
||||
.muted { color:#9fb0be; }
|
||||
.card-list { display:grid; gap:12px; }
|
||||
.actuator-card { background:#111a23; border:1px solid #31404d; border-radius:14px; padding:14px; }
|
||||
.card-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
|
||||
.actuator-card { background:#111a23; border:1px solid #31404d; border-radius:8px; padding:10px; min-width:0; }
|
||||
.actuator-card.selected { border-color:#66dfa9; box-shadow:0 0 0 1px rgba(102,223,169,.3); }
|
||||
.card-title { display:flex; justify-content:space-between; gap:10px; align-items:flex-start; margin-bottom:8px; }
|
||||
.entity-id { overflow-wrap:anywhere; font-weight:800; }
|
||||
.metric-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:8px; margin:10px 0; }
|
||||
.metric { background:#18212b; border:1px solid #2d3a47; border-radius:10px; padding:10px; }
|
||||
.metric-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:6px; margin:8px 0; }
|
||||
.metric { background:#18212b; border:1px solid #2d3a47; border-radius:8px; padding:8px; min-width:0; }
|
||||
.metric strong { display:block; margin-bottom:4px; color:#cfe0ec; font-size:.84rem; }
|
||||
.actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; }
|
||||
.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; }
|
||||
.manual-context { margin-top:12px; background:#111a23; border:1px solid #31404d; border-radius:8px; padding:10px; }
|
||||
.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; }
|
||||
@@ -59,11 +72,12 @@
|
||||
@media (max-width: 760px) {
|
||||
header { padding:18px 14px; }
|
||||
header h1 { font-size:1.55rem; }
|
||||
main { display:block; padding:10px; }
|
||||
section { margin-bottom:12px; padding:14px; border-radius:14px; }
|
||||
main { display:block; padding:8px; }
|
||||
section { margin-bottom:10px; padding:10px; border-radius:8px; }
|
||||
.steps { grid-template-columns:1fr; }
|
||||
.grid-two { grid-template-columns:1fr; }
|
||||
.metric-grid { grid-template-columns:1fr 1fr; }
|
||||
.card-list { grid-template-columns:1fr; }
|
||||
.actions { display:grid; grid-template-columns:1fr; }
|
||||
.actions button, button.compact { width:100%; min-width:0; margin-right:0; }
|
||||
.quick-nav { padding:8px 10px; }
|
||||
@@ -90,7 +104,8 @@
|
||||
</nav>
|
||||
<main>
|
||||
<section class="wide" id="guide">
|
||||
<h2>So gehst du vor</h2>
|
||||
<details class="collapsible">
|
||||
<summary><span>So gehst du vor</span></summary>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<span class="step-number">1</span>
|
||||
@@ -111,6 +126,7 @@
|
||||
<p><strong>Was passiert?</strong> Erst dann darf SillyHome passende Vorhersagen automatisch ausführen. Die Freigabe kann jederzeit gestoppt werden.</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section id="status-section">
|
||||
@@ -190,6 +206,7 @@ let cachedActuators = null;
|
||||
let cachedEntities = null;
|
||||
let cachedDiscovery = null;
|
||||
const ACTUATOR_RESULT_LIMIT = 50;
|
||||
const STATUS_TIMEOUT_MS = 2500;
|
||||
|
||||
function uniqueValues(values) {
|
||||
return [...new Set(values.filter(Boolean))];
|
||||
@@ -208,6 +225,16 @@ async function api(path, options = {}) {
|
||||
return body;
|
||||
}
|
||||
|
||||
async function apiWithTimeout(path, timeoutMs = STATUS_TIMEOUT_MS) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await api(path, {signal: controller.signal});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function lifecycleLabel(record) {
|
||||
const behaviorStatus = record.behavior_status || record.behavior?.status;
|
||||
const lifecycleStatus = record.lifecycle_status || record.lifecycle?.status;
|
||||
@@ -324,48 +351,83 @@ function optionGroups(entities, selectedIds = new Set()) {
|
||||
}
|
||||
|
||||
async function loadOverview() {
|
||||
document.getElementById("configured-actuators").innerHTML = "<p class='muted'>Beobachtete Geräte werden geladen ...</p>";
|
||||
await loadSummaryData();
|
||||
renderConfiguredActuators();
|
||||
void loadStatus();
|
||||
void loadActuatorDiscovery();
|
||||
void loadActuatorSuggestions();
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
const status = document.getElementById("status");
|
||||
const chips = document.getElementById("status-chips");
|
||||
status.innerHTML = "<p class='muted'>Status wird geprüft ...</p>";
|
||||
try {
|
||||
const [health, websocket, ml, reconciliation, actuators] = await Promise.all([
|
||||
api("health"),
|
||||
api("health/websocket"),
|
||||
api("ml/health"),
|
||||
api("v1/actuators/reconciliation/state"),
|
||||
api("v1/actuators"),
|
||||
const [health, websocket, ml, reconciliation, actuators] = await Promise.allSettled([
|
||||
apiWithTimeout("health"),
|
||||
apiWithTimeout("health/websocket"),
|
||||
apiWithTimeout("ml/health"),
|
||||
apiWithTimeout("v1/actuators/reconciliation/state"),
|
||||
apiWithTimeout("v1/actuators/summary"),
|
||||
]);
|
||||
status.innerHTML = `<p class="ok">System bereit</p><p>Letzte automatische Prüfung: ${escapeHtml(reconciliation.last_completed_at || "noch nie")}</p>`;
|
||||
const values = [health, websocket, ml, reconciliation, actuators].map(result =>
|
||||
result.status === "fulfilled" ? result.value : null
|
||||
);
|
||||
const [healthValue, websocketValue, mlValue, reconciliationValue, actuatorValue] = values;
|
||||
const hasError = values.some(value => value === null);
|
||||
status.innerHTML = hasError
|
||||
? "<p class='warn'>Status teilweise verfügbar. Das Dashboard bleibt bedienbar.</p>"
|
||||
: `<p class="ok">System bereit</p><p>Letzte automatische Prüfung: ${escapeHtml(reconciliationValue.last_completed_at || "noch nie")}</p>`;
|
||||
chips.innerHTML = [
|
||||
`<span class="chip">API: ${escapeHtml(health.status)}</span>`,
|
||||
`<span class="chip">WebSocket: ${escapeHtml(websocket.status)}</span>`,
|
||||
`<span class="chip">Lernsystem: ${escapeHtml(ml.status)}</span>`,
|
||||
`<span class="chip">Aktoren: ${actuators.length}</span>`,
|
||||
`<span class="chip">Lernbereite Geräte: ${reconciliation.trained_models}</span>`,
|
||||
`<span class="chip">API: ${escapeHtml(healthValue?.status || "offen")}</span>`,
|
||||
`<span class="chip">WebSocket: ${escapeHtml(websocketValue?.status || "offen")}</span>`,
|
||||
`<span class="chip">Lernsystem: ${escapeHtml(mlValue?.status || "offen")}</span>`,
|
||||
`<span class="chip">Aktoren: ${Array.isArray(actuatorValue) ? actuatorValue.length : "offen"}</span>`,
|
||||
`<span class="chip">Lernbereite Geräte: ${escapeHtml(reconciliationValue?.trained_models ?? "offen")}</span>`,
|
||||
].join("");
|
||||
} catch (error) {
|
||||
status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
|
||||
chips.innerHTML = "";
|
||||
}
|
||||
await loadDashboardData();
|
||||
renderActuatorDiscovery();
|
||||
renderConfiguredActuators();
|
||||
void loadActuatorSuggestions();
|
||||
}
|
||||
|
||||
async function loadSummaryData() {
|
||||
const actuators = await api("v1/actuators/summary");
|
||||
cachedActuators = actuators;
|
||||
cachedEntities = [];
|
||||
}
|
||||
|
||||
async function loadDashboardData() {
|
||||
const [actuators, entities, discovery] = await Promise.all([
|
||||
await loadSummaryData();
|
||||
await loadDiscoveryData();
|
||||
}
|
||||
|
||||
async function loadDiscoveryData() {
|
||||
const discovery = await api("v1/actuators/discovery");
|
||||
cachedDiscovery = discovery;
|
||||
}
|
||||
|
||||
async function loadFullDashboardData() {
|
||||
const [actuators, discovery] = await Promise.all([
|
||||
api("v1/actuators/summary"),
|
||||
Promise.resolve([]),
|
||||
api("v1/actuators/discovery"),
|
||||
]);
|
||||
cachedActuators = actuators;
|
||||
cachedEntities = entities;
|
||||
cachedEntities = [];
|
||||
cachedDiscovery = discovery;
|
||||
}
|
||||
|
||||
async function loadActuatorDiscovery() {
|
||||
if (!cachedActuators || !cachedDiscovery) {
|
||||
await loadDashboardData();
|
||||
const select = document.getElementById("actuator-select");
|
||||
if (select && !cachedDiscovery) {
|
||||
select.innerHTML = `<option value="">Geräteliste lädt im Hintergrund ...</option>`;
|
||||
}
|
||||
if (!cachedActuators) {
|
||||
await loadSummaryData();
|
||||
}
|
||||
if (!cachedDiscovery) {
|
||||
await loadDiscoveryData();
|
||||
}
|
||||
renderActuatorDiscovery();
|
||||
}
|
||||
@@ -384,7 +446,7 @@ function renderActuatorDiscovery() {
|
||||
renderActuatorSelect();
|
||||
} catch (error) {
|
||||
options.innerHTML = "";
|
||||
select.innerHTML = `<option value="">Geräteliste konnte nicht geladen werden</option>`;
|
||||
select.innerHTML = `<option value="">Geräteliste konnte nicht geladen werden: ${escapeHtml(error.message)}</option>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,8 +585,8 @@ async function configureActuator() {
|
||||
}
|
||||
|
||||
async function loadConfiguredActuators() {
|
||||
if (!cachedActuators || !cachedEntities) {
|
||||
await loadDashboardData();
|
||||
if (!cachedActuators) {
|
||||
await loadSummaryData();
|
||||
}
|
||||
renderConfiguredActuators();
|
||||
}
|
||||
@@ -533,25 +595,23 @@ function renderConfiguredActuators() {
|
||||
const box = document.getElementById("configured-actuators");
|
||||
try {
|
||||
const rows = cachedActuators || [];
|
||||
const entities = cachedEntities || [];
|
||||
const entityMap = new Map(entities.map(entity => [entity.entity_id, entity]));
|
||||
const groups = new Map();
|
||||
for (const record of rows) {
|
||||
const entity = entityMap.get(record.actuator_entity_id) || {};
|
||||
const group = entity.area_name || actuatorGroupLabel(record.actuator_entity_id.split(".", 1)[0]);
|
||||
const group = record.area_name || actuatorGroupLabel(record.domain || record.actuator_entity_id.split(".", 1)[0]);
|
||||
if (!groups.has(group)) groups.set(group, []);
|
||||
groups.get(group).push({record, entity});
|
||||
groups.get(group).push({record});
|
||||
}
|
||||
const groupedRows = [...groups.entries()].sort(([left], [right]) => left.localeCompare(right));
|
||||
box.innerHTML = rows.length ? `
|
||||
${groupedRows.map(([group, items]) => `
|
||||
<h3>${escapeHtml(group)}</h3>
|
||||
<details class="group-panel" open>
|
||||
<summary>${escapeHtml(group)} (${items.length})</summary>
|
||||
<div class="card-list">
|
||||
${items.map(({record, entity}) => `
|
||||
${items.map(({record}) => `
|
||||
<article class="actuator-card ${currentActuatorId === record.actuator_entity_id ? "selected" : ""}">
|
||||
<div class="card-title">
|
||||
<div>
|
||||
<div><strong>${escapeHtml(entity.friendly_name || record.actuator_entity_id)}</strong></div>
|
||||
<div><strong>${escapeHtml(record.friendly_name || record.device_name || record.actuator_entity_id)}</strong></div>
|
||||
<div class="entity-id">${escapeHtml(record.actuator_entity_id)}</div>
|
||||
<div class="${(record.behavior_status || record.behavior?.status) === "trained" ? "ok" : "warn"}">${escapeHtml(behaviorLabel(record))}</div>
|
||||
</div>
|
||||
@@ -574,6 +634,7 @@ function renderConfiguredActuators() {
|
||||
</article>
|
||||
`).join("")}
|
||||
</div>
|
||||
</details>
|
||||
`).join("")}` : "<p>Noch keine Aktoren ausgewählt.</p>";
|
||||
} catch (error) {
|
||||
box.textContent = error.message;
|
||||
@@ -628,8 +689,8 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
selected: manualContextIds,
|
||||
};
|
||||
const manualAssignment = `
|
||||
<div class="manual-context">
|
||||
<h3>Kontext selbst festlegen</h3>
|
||||
<details class="manual-context" open>
|
||||
<summary>Kontext selbst festlegen</summary>
|
||||
<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">
|
||||
@@ -659,7 +720,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
|
||||
<button onclick="saveManualAssignment('${escapeHtml(record.actuator_entity_id)}')">Diese Kontext-Auswahl speichern</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>
|
||||
</details>
|
||||
`;
|
||||
const activationButton = record.behavior.mode === "active"
|
||||
? `<button class="danger" onclick="setActivation('${escapeHtml(record.actuator_entity_id)}', false, false, true)">SillyHome stoppen und pausierte HA-Automationen fortsetzen</button>
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "sillyhome-next"
|
||||
version = "0.7.20"
|
||||
version = "0.7.21"
|
||||
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -213,6 +213,23 @@ def test_manual_assignment_endpoint_updates_context(tmp_path: Path) -> None:
|
||||
]
|
||||
|
||||
|
||||
def test_summary_is_lightweight_and_uses_cached_entity_metadata(tmp_path: Path) -> None:
|
||||
with TestClient(app) as client:
|
||||
_install_service(tmp_path)
|
||||
client.get("/v1/actuators/discovery")
|
||||
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
|
||||
|
||||
response = client.get("/v1/actuators/summary")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload[0]["actuator_entity_id"] == "light.abstellkammer"
|
||||
assert payload[0]["friendly_name"] == "Abstellkammer Licht"
|
||||
assert payload[0]["area_name"] == "Abstellkammer"
|
||||
assert "behavior" not in payload[0]
|
||||
assert "numeric_candidates" not in payload[0]
|
||||
|
||||
|
||||
def test_context_options_returns_learnable_entities(tmp_path: Path) -> None:
|
||||
with TestClient(app) as client:
|
||||
_install_service(tmp_path)
|
||||
|
||||
@@ -34,5 +34,10 @@ def test_dashboard_is_served_at_root() -> None:
|
||||
assert "record.behavior.activation_ready" in response.text
|
||||
assert "record.behavior.status ===" not in response.text
|
||||
assert "record.behavior_status || record.behavior?.status" in response.text
|
||||
assert 'api("v1/actuators")' not in response.text
|
||||
assert 'api("v1/actuators/summary")' in response.text
|
||||
assert 'api("v1/entities")' not in response.text
|
||||
assert 'details class="collapsible"' in response.text
|
||||
assert 'class="group-panel"' in response.text
|
||||
assert "Automation-Entwurf" not in response.text
|
||||
assert "Manuelle Overrides" not in response.text
|
||||
|
||||
Reference in New Issue
Block a user