Compare commits

...

2 Commits

Author SHA1 Message Date
faf4099756 Load actuator suggestions asynchronously
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-16 12:14:51 +02:00
1b2b76455a Tighten context onboarding and actuator suggestions
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-06-16 12:06:03 +02:00
12 changed files with 310 additions and 7 deletions

View File

@@ -1,5 +1,20 @@
# Changelog # Changelog
## 0.7.14 - 2026-06-16
- Onboarding-Vorschläge laden im Dashboard nachgelagert, damit Status,
Aktor-Auswahl und bestehende Geräte nicht auf Automation-Discovery warten.
## 0.7.13 - 2026-06-16
- Diagnose-/Schutzsensoren wie Überhitzung und Überlast werden nicht mehr nur
wegen gleicher Strom-/Monitoring-Bereiche automatisch als Lichtkontext
übernommen.
- Verwendete Kontext-Entities können pro Aktor direkt entfernt und damit als
manuelle Zuordnung überschrieben werden.
- Onboarding-Vorschläge zeigen passende, noch nicht eingerichtete Aktoren aus
bestehenden Automationen und naheliegenden Kontexten.
- TV-/Medien-Aktoren über `media_player` und Fernbedienungen über `remote`
werden in Discovery und Auswahl berücksichtigt.
## 0.7.12 - 2026-06-16 ## 0.7.12 - 2026-06-16
- Aktor-Auswahlliste zeigt maximal 50 Treffer gleichzeitig und fordert bei - Aktor-Auswahlliste zeigt maximal 50 Treffer gleichzeitig und fordert bei
größeren Mengen zum Eingrenzen per Suche oder Typfilter auf. größeren Mengen zum Eingrenzen per Suche oder Typfilter auf.

View File

@@ -1,5 +1,5 @@
name: SillyHome Next name: SillyHome Next
version: "0.7.12" version: "0.7.14"
slug: sillyhome_next slug: sillyhome_next
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
url: http://192.168.6.31:3000/pino/sillyhome-next url: http://192.168.6.31:3000/pino/sillyhome-next

View File

@@ -57,7 +57,7 @@ _STOPWORDS = frozenset(
"value", "value",
} }
) )
_GENERIC_AREA_NAMES = frozenset({"monitoring", "system", "technik"}) _GENERIC_AREA_NAMES = frozenset({"energie", "monitoring", "power", "strom", "system", "technik"})
_NUMERIC_AUTO_ACCEPT_SCORE = 0.82 _NUMERIC_AUTO_ACCEPT_SCORE = 0.82
_NUMERIC_AUTO_ACCEPT_MIN_SCORE = 0.5 _NUMERIC_AUTO_ACCEPT_MIN_SCORE = 0.5
_NUMERIC_MIN_MARGIN = 0.18 _NUMERIC_MIN_MARGIN = 0.18
@@ -88,6 +88,7 @@ _DIAGNOSTIC_TOKENS = frozenset({
"diagnostic", "diagnostic",
"firmware", "firmware",
"gesehen", "gesehen",
"heat",
"last", "last",
"linkquality", "linkquality",
"knoten", "knoten",
@@ -100,10 +101,28 @@ _DIAGNOSTIC_TOKENS = frozenset({
"signal", "signal",
"ssid", "ssid",
"status", "status",
"overheat",
"overheating",
"overload",
"uptime", "uptime",
"uberhitzung",
"ueberhitzung",
"ueberlast",
"überhitzung",
"überlast",
"wifi", "wifi",
"zuletzt", "zuletzt",
}) })
_AUTO_CONTEXT_CLASSES = frozenset({
"door",
"garage_door",
"illuminance",
"motion",
"occupancy",
"opening",
"presence",
"window",
})
class ActuatorReconciliationService: class ActuatorReconciliationService:
@@ -628,8 +647,12 @@ class ActuatorReconciliationService:
if context if context
else _NUMERIC_AUTO_ACCEPT_MIN_SCORE else _NUMERIC_AUTO_ACCEPT_MIN_SCORE
) )
can_auto_accept_context = (
not context or _eligible_for_auto_context(actuator, candidate)
)
auto_accepted = ( auto_accepted = (
candidate.score >= minimum_score can_auto_accept_context
and candidate.score >= minimum_score
and confidence >= auto_score and confidence >= auto_score
and (context or margin >= _NUMERIC_MIN_MARGIN) and (context or margin >= _NUMERIC_MIN_MARGIN)
) )
@@ -743,6 +766,23 @@ def _has_context_relationship(actuator: HaEntitySummary, entity: HaEntitySummary
) )
def _eligible_for_auto_context(
actuator: HaEntitySummary,
candidate: AssignmentCandidate,
) -> bool:
device_class = candidate.device_class or ""
if device_class in _AUTO_CONTEXT_CLASSES:
return True
if (
actuator.device_name
and candidate.device_name
and actuator.device_name == candidate.device_name
and candidate.domain in {"light", "switch"}
):
return True
return False
def _score_candidate( def _score_candidate(
actuator: HaEntitySummary, actuator: HaEntitySummary,
entity: HaEntitySummary, entity: HaEntitySummary,

View File

@@ -8,7 +8,7 @@ from app.actuators.models import ActuatorRecord, ReconciliationState
from app.actuators.store import ActuatorStore from app.actuators.store import ActuatorStore
from app.behavior.engine import BehaviorEngine from app.behavior.engine import BehaviorEngine
from app.dependencies import get_ha_reader from app.dependencies import get_ha_reader
from app.ha.discovery import EntityRole from app.ha.discovery import DiscoveredEntity, EntityRole
from app.ha.exceptions import HaClientError from app.ha.exceptions import HaClientError
from app.ha.models import HaEntitySummary from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader from app.ha.reader import HaReader
@@ -43,6 +43,18 @@ class FeedbackRequest(BaseModel):
expected_state: str | None = Field(default=None, max_length=100) expected_state: str | None = Field(default=None, max_length=100)
class ActuatorSuggestion(BaseModel):
entity_id: str
domain: str
friendly_name: str | None = None
area_name: str | None = None
device_name: str | None = None
confidence: float
reason: str
related_automation_count: int = 0
likely_context_count: int = 0
@router.get("/discovery", response_model=list[HaEntitySummary]) @router.get("/discovery", response_model=list[HaEntitySummary])
def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> 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()} entities = {entity.entity_id: entity for entity in ha_reader.read_entities()}
@@ -58,6 +70,66 @@ def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaE
return [entities[entity_id] for entity_id in actuator_ids if entity_id in entities] return [entities[entity_id] for entity_id in actuator_ids if entity_id in entities]
@router.get("/suggestions", response_model=list[ActuatorSuggestion])
def suggest_actuators(
request: Request,
ha_reader: HaReader = Depends(get_ha_reader),
) -> list[ActuatorSuggestion]:
entities = {entity.entity_id: entity for entity in ha_reader.read_entities()}
discovered = {entity.entity_id: entity for entity in ha_reader.discover()}
configured_ids = {record.actuator_entity_id for record in _service(request).list_configured()}
actuator_ids = _deduplicate_actuator_ids(
[
(entity.entity_id, entity.category)
for entity in discovered.values()
if entity.role is EntityRole.ACTUATOR
],
entities,
)
suggestions: list[ActuatorSuggestion] = []
for entity_id in actuator_ids:
if entity_id in configured_ids:
continue
entity = entities.get(entity_id)
if entity is None:
continue
try:
automations = ha_reader.find_automations_for_entity(entity_id)
except Exception:
automations = []
context_count = _likely_context_count(entity, entities, discovered)
if not automations and context_count == 0:
continue
confidence = 1.0 if automations else min(0.85, 0.35 + context_count * 0.1)
reason_parts = []
if automations:
reason_parts.append(f"{len(automations)} passende HA-Automation(en)")
if context_count:
reason_parts.append(f"{context_count} naheliegende Kontext-Entity(s)")
suggestions.append(
ActuatorSuggestion(
entity_id=entity.entity_id,
domain=entity.domain,
friendly_name=entity.friendly_name,
area_name=entity.area_name,
device_name=entity.device_name,
confidence=round(confidence, 4),
reason=", ".join(reason_parts),
related_automation_count=len(automations),
likely_context_count=context_count,
)
)
return sorted(
suggestions,
key=lambda item: (
-item.related_automation_count,
-item.confidence,
item.area_name or "",
item.friendly_name or item.entity_id,
),
)[:30]
@router.get("/context-options", response_model=list[HaEntitySummary]) @router.get("/context-options", response_model=list[HaEntitySummary])
def context_options( def context_options(
request: Request, request: Request,
@@ -294,3 +366,67 @@ def _actuator_duplicate_key(entity: HaEntitySummary, category: str) -> str:
if entity.device_name and category in {"light", "switch_socket", "button"}: if entity.device_name and category in {"light", "switch_socket", "button"}:
return f"device-name:{entity.device_name.lower()}:control" return f"device-name:{entity.device_name.lower()}:control"
return f"entity:{entity.entity_id}" return f"entity:{entity.entity_id}"
def _likely_context_count(
actuator: HaEntitySummary,
entities: dict[str, HaEntitySummary],
discovered: dict[str, DiscoveredEntity],
) -> int:
actuator_tokens = _tokens(actuator)
count = 0
for entity in entities.values():
if entity.entity_id == actuator.entity_id:
continue
descriptor = discovered.get(entity.entity_id)
role = descriptor.role if descriptor is not None else None
if role not in {
EntityRole.MEASUREMENT,
EntityRole.BINARY_CONTEXT,
EntityRole.CONTEXT,
}:
continue
if entity.device_class not in {
"door",
"energy",
"garage_door",
"humidity",
"illuminance",
"motion",
"occupancy",
"opening",
"power",
"presence",
"temperature",
"window",
}:
continue
same_area = bool(
actuator.area_name
and entity.area_name
and actuator.area_name == entity.area_name
)
same_device = bool(
actuator.device_id
and entity.device_id
and actuator.device_id == entity.device_id
)
token_match = bool(actuator_tokens.intersection(_tokens(entity)))
if same_area or same_device or token_match:
count += 1
return count
def _tokens(entity: HaEntitySummary) -> set[str]:
values = [
entity.entity_id,
entity.friendly_name,
entity.area_name,
entity.device_name,
]
tokens: set[str] = set()
for value in values:
if not value:
continue
tokens.update(token for token in value.lower().replace("_", " ").split() if len(token) > 2)
return tokens

View File

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

View File

@@ -92,7 +92,9 @@ _ACTUATOR_DOMAINS = frozenset({
"input_button", "input_button",
"lock", "lock",
"light", "light",
"media_player",
"number", "number",
"remote",
"siren", "siren",
"switch", "switch",
"valve", "valve",
@@ -225,6 +227,8 @@ def _actuator_category(entity: HaEntitySummary) -> str:
return "lock" return "lock"
if entity.domain == "fan": if entity.domain == "fan":
return "fan" return "fan"
if entity.domain in {"media_player", "remote"}:
return "media_tv"
if entity.domain in {"input_boolean", "number"}: if entity.domain in {"input_boolean", "number"}:
return "helper" return "helper"
return entity.domain return entity.domain

View File

@@ -102,7 +102,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app = FastAPI( app = FastAPI(
title="SillyHome Next API", title="SillyHome Next API",
description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.",
version="0.7.12", version="0.7.14",
lifespan=lifespan, lifespan=lifespan,
) )
app.state.settings = load_settings() app.state.settings = load_settings()

View File

@@ -142,6 +142,8 @@
<option value="lock">Schlösser</option> <option value="lock">Schlösser</option>
<option value="fan">Lüftung / Ventilatoren</option> <option value="fan">Lüftung / Ventilatoren</option>
<option value="humidifier">Befeuchter / Entfeuchter</option> <option value="humidifier">Befeuchter / Entfeuchter</option>
<option value="media_player">TV / Medien</option>
<option value="remote">Fernbedienungen</option>
<option value="number">Numerische Helper</option> <option value="number">Numerische Helper</option>
<option value="valve">Ventile</option> <option value="valve">Ventile</option>
</select> </select>
@@ -157,6 +159,7 @@
</select> </select>
<button onclick="configureActuator()">Gerät hinzufügen und Beobachtung starten</button> <button onclick="configureActuator()">Gerät hinzufügen und Beobachtung starten</button>
<p id="actuator-config-result" class="muted">Noch kein Aktor ausgewählt.</p> <p id="actuator-config-result" class="muted">Noch kein Aktor ausgewählt.</p>
<div id="actuator-suggestions" class="card-list"></div>
</section> </section>
<section class="wide" id="observed"> <section class="wide" id="observed">
@@ -303,6 +306,7 @@ async function loadOverview() {
chips.innerHTML = ""; chips.innerHTML = "";
} }
await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators()]); await Promise.all([loadActuatorDiscovery(), loadConfiguredActuators()]);
void loadActuatorSuggestions();
} }
async function loadActuatorDiscovery() { async function loadActuatorDiscovery() {
@@ -325,6 +329,32 @@ async function loadActuatorDiscovery() {
} }
} }
async function loadActuatorSuggestions() {
const box = document.getElementById("actuator-suggestions");
if (!box) return;
try {
const suggestions = await api("v1/actuators/suggestions");
box.innerHTML = suggestions.length ? `
<h3>Vorschläge aus bestehenden Zusammenhängen</h3>
${suggestions.slice(0, 8).map(item => `
<article class="actuator-card">
<div class="card-title">
<div>
<div class="entity-id">${escapeHtml(item.entity_id)}</div>
<div class="muted">${escapeHtml(item.area_name || item.device_name || item.domain)}</div>
</div>
<span class="chip">${Math.round(item.confidence * 100)} %</span>
</div>
<p class="muted">${escapeHtml(item.reason)}</p>
<button class="secondary" onclick="configureSuggestedActuator('${escapeHtml(item.entity_id)}')">Vorschlag übernehmen</button>
</article>
`).join("")}
` : "";
} catch (_) {
box.innerHTML = "";
}
}
function actuatorGroupLabel(domain) { function actuatorGroupLabel(domain) {
const labels = { const labels = {
button: "Buttons", button: "Buttons",
@@ -333,7 +363,9 @@ function actuatorGroupLabel(domain) {
input_boolean: "Helper-Schalter", input_boolean: "Helper-Schalter",
input_button: "Helper-Buttons", input_button: "Helper-Buttons",
lock: "Schlösser", lock: "Schlösser",
media_player: "TV / Medien",
number: "Numerische Helper", number: "Numerische Helper",
remote: "Fernbedienungen",
switch: "Schalter / Steckdosen", switch: "Schalter / Steckdosen",
cover: "Rollläden / Cover", cover: "Rollläden / Cover",
fan: "Lüftung / Ventilatoren", fan: "Lüftung / Ventilatoren",
@@ -486,6 +518,14 @@ async function showActuator(actuatorId, evaluationMessage = "") {
.filter(candidate => contexts.includes(candidate.entity_id)) .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>: ${candidate.evidence.map(escapeHtml).join(", ") || "statistisch relevanter Kandidat"}</li>`)
.join(""); .join("");
const currentContextControls = contexts.length
? `<ul>${contexts.map(entityId => `
<li>
<code>${escapeHtml(entityId)}</code>
<button class="secondary compact" onclick="removeContextEntity('${escapeHtml(record.actuator_entity_id)}', '${escapeHtml(entityId)}')">Entfernen</button>
</li>
`).join("")}</ul>`
: "<p class='muted'>Noch keine Kontext-Entity ausgewählt.</p>";
const prediction = record.behavior.prediction; const prediction = record.behavior.prediction;
const learnedAutomationActions = record.behavior.patterns.filter( const learnedAutomationActions = record.behavior.patterns.filter(
pattern => pattern.source === "automation", pattern => pattern.source === "automation",
@@ -601,6 +641,8 @@ async function showActuator(actuatorId, evaluationMessage = "") {
${automationControls} ${automationControls}
<h3>Welche Zusammenhänge automatisch verwendet werden</h3> <h3>Welche Zusammenhänge automatisch verwendet werden</h3>
${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"} ${evidence ? `<ul>${evidence}</ul>` : "<p class='warn'>Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.</p>"}
<h3>Verwendete Sensoren/Zustände ändern</h3>
${currentContextControls}
${manualAssignment} ${manualAssignment}
`; `;
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"}); document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
@@ -635,6 +677,34 @@ async function saveManualAssignment(actuatorId) {
} }
} }
async function removeContextEntity(actuatorId, entityId) {
try {
const record = await api(`v1/actuators/${encodeURIComponent(actuatorId)}`);
const numericEntityId = record.assignment.selected_numeric_entity_id === entityId
? null
: record.assignment.selected_numeric_entity_id;
const contextEntityIds = (record.assignment.selected_context_entity_ids || [])
.filter(id => id !== entityId);
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/assignment`, {
method: "POST",
body: JSON.stringify({
numeric_entity_id: numericEntityId,
context_entity_ids: contextEntityIds,
note: `Entity ${entityId} entfernt`,
}),
});
await loadConfiguredActuators();
await showActuator(actuatorId, "Kontext-Entity entfernt.");
} catch (error) {
alert(error.message);
}
}
async function configureSuggestedActuator(actuatorId) {
document.getElementById("actuator-input").value = actuatorId;
await configureActuator();
}
async function evaluateActuator(actuatorId) { async function evaluateActuator(actuatorId) {
try { try {
const record = await api( const record = await api(

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "sillyhome-next" name = "sillyhome-next"
version = "0.7.12" version = "0.7.14"
description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant" description = "Lokales Smart-Home-Intelligenzsystem für Home Assistant"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [

View File

@@ -277,6 +277,39 @@ def test_reconciliation_ignores_generic_monitoring_area_for_automatic_context(
assert record.lifecycle.status is LifecycleStatus.ARCHIVED assert record.lifecycle.status is LifecycleStatus.ARCHIVED
def test_reconciliation_does_not_auto_select_overload_sensors_by_power_area(
tmp_path: Path,
) -> None:
entities = [
HaEntitySummary(
entity_id="light.treppe_unten",
domain="light",
friendly_name="Licht Treppe Unten",
area_name="Strom",
),
HaEntitySummary(
entity_id="binary_sensor.shelly_schrank_channel_1_overload",
domain="binary_sensor",
device_class="problem",
friendly_name="Shelly Schrank Channel 1 Überlast",
area_name="Strom",
),
HaEntitySummary(
entity_id="binary_sensor.terrasse_terasse_overheating",
domain="binary_sensor",
device_class="problem",
friendly_name="Terrasse Terasse Überhitzung",
area_name="Strom",
),
]
service = _service(tmp_path, entities, {})
record = service.configure_actuator("light.treppe_unten")
assert record.assignment.selected_context_entity_ids == []
assert all(candidate.auto_accepted is False for candidate in record.context_candidates)
def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Path) -> None: def test_manual_assignment_persists_and_wins_over_automatic_mapping(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc) start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [ entities = [

View File

@@ -558,6 +558,7 @@ def test_cooldown_allows_opposite_follow_up_action(tmp_path: Path) -> None:
("domain", "state", "service"), ("domain", "state", "service"),
[ [
("light", "on", "turn_on"), ("light", "on", "turn_on"),
("media_player", "off", "turn_off"),
("switch", "off", "turn_off"), ("switch", "off", "turn_off"),
("cover", "open", "open_cover"), ("cover", "open", "open_cover"),
("cover", "closed", "close_cover"), ("cover", "closed", "close_cover"),

View File

@@ -91,6 +91,10 @@ def test_discovery_filters_domain_and_learnable() -> None:
HaEntitySummary(entity_id="input_boolean.sleep_mode", domain="input_boolean"), HaEntitySummary(entity_id="input_boolean.sleep_mode", domain="input_boolean"),
"helper", "helper",
), ),
(
HaEntitySummary(entity_id="media_player.tv", domain="media_player"),
"media_tv",
),
( (
HaEntitySummary( HaEntitySummary(
entity_id="sensor.brightness", entity_id="sensor.brightness",