Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8bee92e64 |
11
CHANGELOG.md
11
CHANGELOG.md
@@ -1,5 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## 0.7.18 - 2026-06-16
|
||||
- Dashboard lädt Aktoren, Entities und Discovery nur noch einmal pro Refresh und
|
||||
rendert daraus Auswahl und Übersicht ohne doppelte API-Ladewege.
|
||||
- Manuelle Kontext-Evidenz wird dedupliziert, damit Hinweise wie
|
||||
"Manuell vom Nutzer als relevant festgelegt" nicht mehrfach erscheinen.
|
||||
- Kontextauswahl ist vollständiger: Feuchte, Wetter, Licht-/Schalterzustände,
|
||||
Bewegungs-/Tür-/Präsenzmelder, PV/Akku/Einspeisung und Helper werden sauberer
|
||||
kategorisiert und per Suche/Kategorie erreichbar.
|
||||
- Domainspezifische Zuordnung geschärft: Lüftungen bevorzugen Feuchte/Temperatur,
|
||||
Lichter Helligkeit/Bewegung/Tür/Präsenz, Heizungen Temperatur/Anwesenheit/Wetter.
|
||||
|
||||
## 0.7.17 - 2026-06-16
|
||||
- WebSocket-Eventpfad ist schneller: irrelevante HA-State-Changes werden vor
|
||||
dem teuren State-Cache-Listenbau verworfen.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: SillyHome Next
|
||||
version: "0.7.17"
|
||||
version: "0.7.18"
|
||||
slug: sillyhome_next
|
||||
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
|
||||
url: http://192.168.6.31:3000/pino/sillyhome-next
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 => `
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "sillyhome-next"
|
||||
version = "0.7.17"
|
||||
version = "0.7.18"
|
||||
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -310,6 +310,48 @@ def test_reconciliation_does_not_auto_select_overload_sensors_by_power_area(
|
||||
assert all(candidate.auto_accepted is False for candidate in record.context_candidates)
|
||||
|
||||
|
||||
def test_fan_prefers_humidity_over_power_sensor(tmp_path: Path) -> None:
|
||||
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||
entities = [
|
||||
HaEntitySummary(
|
||||
entity_id="fan.bad_lueftung",
|
||||
domain="fan",
|
||||
friendly_name="Bad Lüftung",
|
||||
area_name="Bad",
|
||||
),
|
||||
HaEntitySummary(
|
||||
entity_id="sensor.bad_luftfeuchtigkeit",
|
||||
domain="sensor",
|
||||
device_class="humidity",
|
||||
state_class="measurement",
|
||||
unit_of_measurement="%",
|
||||
friendly_name="Bad Luftfeuchtigkeit",
|
||||
area_name="Bad",
|
||||
),
|
||||
HaEntitySummary(
|
||||
entity_id="sensor.bad_power",
|
||||
domain="sensor",
|
||||
device_class="power",
|
||||
state_class="measurement",
|
||||
unit_of_measurement="W",
|
||||
friendly_name="Bad Leistung",
|
||||
area_name="Bad",
|
||||
),
|
||||
]
|
||||
service = _service(
|
||||
tmp_path,
|
||||
entities,
|
||||
{
|
||||
"sensor.bad_luftfeuchtigkeit": _points(8, start, 55.0),
|
||||
"sensor.bad_power": _points(8, start, 5.0),
|
||||
},
|
||||
)
|
||||
|
||||
record = service.configure_actuator("fan.bad_lueftung")
|
||||
|
||||
assert record.assignment.selected_numeric_entity_id == "sensor.bad_luftfeuchtigkeit"
|
||||
|
||||
|
||||
def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Path) -> None:
|
||||
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||
entities = [
|
||||
@@ -358,3 +400,39 @@ def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Pa
|
||||
assert record.assignment.selected_context_entity_ids == ["sensor.abstellkammer_illuminance"]
|
||||
assert record.assignment.source is AssignmentSource.MANUAL
|
||||
assert record.manual_override is not None
|
||||
|
||||
|
||||
def test_manual_assignment_evidence_is_not_duplicated(tmp_path: Path) -> None:
|
||||
entities = [
|
||||
HaEntitySummary(
|
||||
entity_id="light.abstellkammer",
|
||||
domain="light",
|
||||
friendly_name="Abstellkammer Licht",
|
||||
area_name="Abstellkammer",
|
||||
),
|
||||
HaEntitySummary(
|
||||
entity_id="binary_sensor.abstellkammer_motion",
|
||||
domain="binary_sensor",
|
||||
device_class="motion",
|
||||
friendly_name="Abstellkammer Bewegung",
|
||||
area_name="Abstellkammer",
|
||||
),
|
||||
]
|
||||
service = _service(tmp_path, entities, {})
|
||||
service.configure_actuator("light.abstellkammer")
|
||||
for _ in range(3):
|
||||
service.set_manual_assignment(
|
||||
"light.abstellkammer",
|
||||
numeric_entity_id=None,
|
||||
context_entity_ids=["binary_sensor.abstellkammer_motion"],
|
||||
note="Manuell gesetzt",
|
||||
)
|
||||
|
||||
record = service.get_actuator("light.abstellkammer")
|
||||
candidate = next(
|
||||
item
|
||||
for item in record.context_candidates
|
||||
if item.entity_id == "binary_sensor.abstellkammer_motion"
|
||||
)
|
||||
|
||||
assert candidate.evidence.count("Manuell vom Nutzer als relevant festgelegt.") == 1
|
||||
|
||||
Reference in New Issue
Block a user