Optimize dashboard categories and context loading
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled

This commit is contained in:
2026-06-17 00:02:19 +02:00
parent 9ddb065f62
commit f8bee92e64
9 changed files with 203 additions and 27 deletions

View File

@@ -72,29 +72,42 @@ _MANUAL_CONTEXT_DOMAINS = frozenset({
"device_tracker",
"fan",
"humidifier",
"input_boolean",
"input_number",
"input_select",
"light",
"media_player",
"person",
"remote",
"scene",
"sensor",
"sun",
"switch",
"weather",
})
_CONTEXT_SUGGESTION_LIMIT = 120
_CONTEXT_SUGGESTION_LIMIT = 500
_OUTDOOR_TOKENS = frozenset({"aussen", "außen", "outdoor", "garten", "terrasse", "balkon"})
_DIAGNOSTIC_TOKENS = frozenset({
"basic",
"battery",
"bytes",
"connect",
"count",
"data",
"diagnostic",
"firmware",
"gesehen",
"heat",
"inbytes",
"interface",
"last",
"linkquality",
"knoten",
"knotens",
"mqtt",
"node",
"outbytes",
"pfsense",
"reason",
"restart",
"rssi",
@@ -105,6 +118,7 @@ _DIAGNOSTIC_TOKENS = frozenset({
"overheating",
"overload",
"uptime",
"vpn",
"uberhitzung",
"ueberhitzung",
"ueberlast",
@@ -175,13 +189,10 @@ class ActuatorReconciliationService:
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:
if not selected and _is_diagnostic_context(entity):
continue
if not selected and not _has_context_relationship(actuator, entity):
score = max(score, 0.01)
ranked.append((score, _context_sort_group(entity), entity))
ranked.sort(
key=lambda item: (
@@ -849,12 +860,17 @@ def _merge_manual_candidates(
for entity_id in selected_entity_ids:
existing = by_id.get(entity_id)
if existing is not None:
evidence = [
item
for item in existing.evidence
if item != "Manuell vom Nutzer als relevant festgelegt."
]
by_id[entity_id] = existing.model_copy(
update={
"auto_accepted": True,
"confidence": 1.0,
"evidence": [
*existing.evidence,
*evidence,
"Manuell vom Nutzer als relevant festgelegt.",
],
}
@@ -883,13 +899,28 @@ def _merge_manual_candidates(
def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]:
if context:
return frozenset({"door", "garage_door", "motion", "occupancy", "opening", "presence"})
mapping = {
"climate": {"occupancy", "presence", "window"},
"cover": {"illuminance", "wind_speed"},
"fan": {"humidity", "moisture", "occupancy", "presence", "temperature"},
"humidifier": {"humidity", "moisture", "temperature"},
"light": {"door", "garage_door", "motion", "occupancy", "opening", "presence", "window"},
"switch": {"door", "garage_door", "motion", "occupancy", "opening", "presence", "window"},
}
return frozenset(
mapping.get(
domain,
{"door", "garage_door", "motion", "occupancy", "opening", "presence"},
)
)
mapping = {
"climate": {"temperature", "humidity", "power"},
"climate": {"temperature", "humidity"},
"cover": {"illuminance", "temperature", "wind_speed"},
"fan": {"temperature", "humidity", "power"},
"humidifier": {"humidity", "temperature", "power"},
"light": {"illuminance", "power", "energy"},
"fan": {"temperature", "humidity", "moisture"},
"humidifier": {"humidity", "moisture", "temperature"},
"light": {"illuminance"},
"media_player": {"power", "energy"},
"remote": {"battery"},
"switch": {"power", "energy", "current"},
"valve": {"temperature", "pressure", "humidity"},
}

View File

@@ -900,6 +900,8 @@ def predict_behavior(
def service_for_state(domain: str, target_state: str) -> str | None:
if domain in {"fan", "humidifier", "light", "media_player", "remote", "switch"}:
return {"on": "turn_on", "off": "turn_off"}.get(target_state)
if domain == "scene":
return "turn_on" if target_state == "on" else None
if domain == "cover":
return {"open": "open_cover", "closed": "close_cover"}.get(target_state)
return None

View File

@@ -95,6 +95,7 @@ _ACTUATOR_DOMAINS = frozenset({
"media_player",
"number",
"remote",
"scene",
"siren",
"switch",
"valve",
@@ -229,6 +230,8 @@ def _actuator_category(entity: HaEntitySummary) -> str:
return "fan"
if entity.domain in {"media_player", "remote"}:
return "media_tv"
if entity.domain == "scene":
return "scene"
if entity.domain in {"input_boolean", "number"}:
return "helper"
return entity.domain

View File

@@ -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.17",
version="0.7.18",
lifespan=lifespan,
)
app.state.settings = load_settings()

View File

@@ -144,6 +144,7 @@
<option value="humidifier">Befeuchter / Entfeuchter</option>
<option value="media_player">TV / Medien</option>
<option value="remote">Fernbedienungen</option>
<option value="scene">Szenen</option>
<option value="number">Numerische Helper</option>
<option value="valve">Ventile</option>
</select>
@@ -185,8 +186,15 @@ let currentActuatorId = null;
let actuatorChoices = [];
let contextOptions = [];
let manualContextState = {options: [], selected: new Set()};
let cachedActuators = null;
let cachedEntities = null;
let cachedDiscovery = null;
const ACTUATOR_RESULT_LIMIT = 50;
function uniqueValues(values) {
return [...new Set(values.filter(Boolean))];
}
async function api(path, options = {}) {
const response = await fetch(path, {headers: {"Content-Type": "application/json"}, ...options});
const body = response.status === 204 ? null : await response.json().catch(() => ({}));
@@ -251,14 +259,33 @@ function matchesSearch(entity, query) {
function categoryForEntity(entity) {
const cls = entity.device_class || "";
const text = normalizedSearch([
entity.entity_id,
entity.friendly_name,
entity.area_name,
entity.device_name,
].filter(Boolean).join(" "));
if (["pv", "solar", "akku", "batterie", "battery", "einspeisung", "wechselrichter"].some(token => text.includes(token))) {
return "PV / Akku / Einspeisung";
}
if (entity.domain === "fan") return "Lüftung / Ventilatoren";
if (entity.domain === "climate") return "Heizung / Klima";
if (entity.domain === "weather") return "Wetter";
if (entity.domain === "person" || entity.domain === "device_tracker") return "Anwesenheit / Personen";
if (entity.domain === "cover") return "Rollläden / Cover";
if (entity.domain === "light") return "Lichtzustände";
if (entity.domain === "switch") return "Schalter / Helper";
if (entity.domain === "switch") return "Schalter / Steckdosen";
if (entity.domain.startsWith("input_")) return "Helper";
if (entity.domain === "scene") return "Szenen";
if (entity.domain === "media_player" || entity.domain === "remote") return "TV / Medien";
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 (["smoke", "safety", "problem"].includes(cls)) return "Sicherheit / Diagnose";
if (["humidity", "moisture"].includes(cls)) return "Luftfeuchtigkeit";
if (["temperature"].includes(cls)) return "Temperatur";
if (["power", "energy", "current", "voltage"].includes(cls)) return "Strom / Energie";
if (["battery", "signal_strength"].includes(cls)) return "Batterie / Signal";
if (entity.domain === "binary_sensor") return "Binäre Sensoren";
if (entity.domain === "sensor") return "Weitere Messsensoren";
return "Weitere Zustände";
@@ -305,18 +332,36 @@ async function loadOverview() {
status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
chips.innerHTML = "";
}
await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators()]);
await loadDashboardData();
renderActuatorDiscovery();
renderConfiguredActuators();
void loadActuatorSuggestions();
}
async function loadDashboardData() {
const [actuators, entities, discovery] = await Promise.all([
api("v1/actuators"),
api("v1/entities"),
api("v1/actuators/discovery"),
]);
cachedActuators = actuators;
cachedEntities = entities;
cachedDiscovery = discovery;
}
async function loadActuatorDiscovery() {
if (!cachedActuators || !cachedDiscovery) {
await loadDashboardData();
}
renderActuatorDiscovery();
}
function renderActuatorDiscovery() {
const options = document.getElementById("actuator-options");
const select = document.getElementById("actuator-select");
try {
const [available, configured] = await Promise.all([
api("v1/actuators/discovery"),
api("v1/actuators"),
]);
const available = cachedDiscovery || [];
const configured = cachedActuators || [];
const configuredIds = new Set(configured.map(record => record.actuator_entity_id));
actuatorChoices = available.filter(entity => !configuredIds.has(entity.entity_id));
options.innerHTML = actuatorChoices.slice(0, 120).map(entity =>
@@ -366,6 +411,7 @@ function actuatorGroupLabel(domain) {
media_player: "TV / Medien",
number: "Numerische Helper",
remote: "Fernbedienungen",
scene: "Szenen",
switch: "Schalter / Steckdosen",
cover: "Rollläden / Cover",
fan: "Lüftung / Ventilatoren",
@@ -463,12 +509,17 @@ async function configureActuator() {
}
async function loadConfiguredActuators() {
if (!cachedActuators || !cachedEntities) {
await loadDashboardData();
}
renderConfiguredActuators();
}
function renderConfiguredActuators() {
const box = document.getElementById("configured-actuators");
try {
const [rows, entities] = await Promise.all([
api("v1/actuators"),
api("v1/entities"),
]);
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) {
@@ -532,7 +583,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
].filter(Boolean);
const evidence = [...record.numeric_candidates, ...record.context_candidates]
.filter(candidate => contexts.includes(candidate.entity_id))
.map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${candidate.evidence.map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`)
.map(candidate => `<li><strong>${escapeHtml(candidate.friendly_name || candidate.entity_id)}</strong>: ${uniqueValues(candidate.evidence).map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`)
.join("");
const currentContextControls = contexts.length
? `<ul>${contexts.map(entityId => `