diff --git a/CHANGELOG.md b/CHANGELOG.md
index b8b518d..95d79f0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
# Changelog
+## 0.7.4 - 2026-06-14
+- Kontext-Auswahl liefert jetzt aktorbezogene Vorschläge statt einer pauschalen
+ Roh-Liste aller Sensoren und Zustände
+- Dashboard-Auswahl für Aktoren und Kontext nach Typ/Kategorie gruppiert und
+ durchsuchbar; lange Listen werden begrenzt statt mobil unbedienbar zu werden
+- Manuelle Entity-ID-Eingabe ergänzt, damit relevante Sensoren auch ohne
+ Dropdown-Treffer gespeichert werden können
+- Irrelevante System-/VPN-/pfSense-Sensoren tauchen bei Lichtaktoren ohne
+ fachlichen Bezug nicht mehr als Standardvorschläge auf
+
## 0.7.3 - 2026-06-14
- Automatische Kontextzuordnung ignoriert generische Bereiche wie `Monitoring`,
damit System-/Disk-/Überhitzungssensoren nicht fälschlich Lichtaktoren erklären
diff --git a/addon/config.yaml b/addon/config.yaml
index 0a2de0d..7f2ae29 100644
--- a/addon/config.yaml
+++ b/addon/config.yaml
@@ -1,5 +1,5 @@
name: SillyHome Next
-version: "0.7.3"
+version: "0.7.4"
slug: sillyhome_next
description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren
url: http://192.168.6.31:3000/pino/sillyhome-next
diff --git a/app/actuators/lifecycle.py b/app/actuators/lifecycle.py
index a216af5..41681ce 100644
--- a/app/actuators/lifecycle.py
+++ b/app/actuators/lifecycle.py
@@ -65,6 +65,20 @@ _CONTEXT_AUTO_ACCEPT_SCORE = 0.78
_CONTEXT_AUTO_ACCEPT_MIN_SCORE = 0.3
_MAX_CONTEXT_SELECTIONS = 5
_AUDIT_LIMIT = 20
+_MANUAL_CONTEXT_DOMAINS = frozenset({
+ "binary_sensor",
+ "climate",
+ "cover",
+ "device_tracker",
+ "fan",
+ "humidifier",
+ "light",
+ "person",
+ "sensor",
+ "switch",
+ "weather",
+})
+_CONTEXT_SUGGESTION_LIMIT = 120
class ActuatorReconciliationService:
@@ -91,6 +105,46 @@ class ActuatorReconciliationService:
def get_actuator(self, actuator_entity_id: str) -> ActuatorRecord:
return self._store.get(actuator_entity_id)
+ def suggest_context_options(
+ self,
+ actuator_entity_id: str,
+ *,
+ limit: int = _CONTEXT_SUGGESTION_LIMIT,
+ ) -> list[HaEntitySummary]:
+ entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()}
+ discovered = {entity.entity_id: entity for entity in self._ha_reader.discover()}
+ actuator = entities.get(actuator_entity_id)
+ if actuator is None:
+ raise KeyError("Aktuator-Konfiguration nicht gefunden.")
+ selected_ids = _selected_context_ids(self._store.get(actuator_entity_id))
+ ranked: list[tuple[float, str, HaEntitySummary]] = []
+ for entity in entities.values():
+ if entity.entity_id == actuator_entity_id or entity.domain not in _MANUAL_CONTEXT_DOMAINS:
+ continue
+ role = _manual_context_role(entity, discovered.get(entity.entity_id))
+ score, _ = _score_candidate(
+ actuator,
+ entity,
+ role,
+ context=role is not EntityRole.MEASUREMENT,
+ )
+ selected = entity.entity_id in selected_ids
+ if selected:
+ score = max(score, 1.0)
+ if not selected and score < 0.1:
+ continue
+ ranked.append((score, _context_sort_group(entity), entity))
+ ranked.sort(
+ key=lambda item: (
+ -item[0],
+ item[1],
+ item[2].area_name or "",
+ item[2].friendly_name or item[2].entity_id,
+ item[2].entity_id,
+ )
+ )
+ return [entity for _, _, entity in ranked[:limit]]
+
def delete_actuator(self, actuator_entity_id: str) -> None:
model_id = model_id_for_actuator(actuator_entity_id)
self._registry.archive(model_id)
@@ -592,6 +646,47 @@ def _filter_candidates(
return result
+def _selected_context_ids(record: ActuatorRecord) -> set[str]:
+ result = set(record.assignment.selected_context_entity_ids)
+ if record.assignment.selected_numeric_entity_id:
+ result.add(record.assignment.selected_numeric_entity_id)
+ if record.manual_override is not None:
+ result.update(record.manual_override.context_entity_ids)
+ if record.manual_override.numeric_entity_id:
+ result.add(record.manual_override.numeric_entity_id)
+ return result
+
+
+def _manual_context_role(
+ entity: HaEntitySummary,
+ discovered: DiscoveredEntity | None,
+) -> EntityRole:
+ if discovered is not None and discovered.role is not EntityRole.UNSUPPORTED:
+ return discovered.role
+ if entity.domain == "sensor":
+ return EntityRole.MEASUREMENT
+ if entity.domain == "binary_sensor":
+ return EntityRole.BINARY_CONTEXT
+ return EntityRole.CONTEXT
+
+
+def _context_sort_group(entity: HaEntitySummary) -> str:
+ device_class = entity.device_class or ""
+ if device_class in {"motion", "occupancy", "presence"}:
+ return "01_presence"
+ if device_class in {"illuminance"}:
+ return "02_brightness"
+ if device_class in {"door", "garage_door", "opening", "window"}:
+ return "03_opening"
+ if device_class in {"humidity", "moisture"}:
+ return "04_humidity"
+ if device_class in {"power", "energy", "current", "voltage"}:
+ return "05_power"
+ if entity.domain in {"light", "switch"}:
+ return "06_states"
+ return f"20_{entity.domain}_{device_class}"
+
+
def _score_candidate(
actuator: HaEntitySummary,
entity: HaEntitySummary,
@@ -637,6 +732,14 @@ def _score_candidate(
if context and role is EntityRole.BINARY_CONTEXT:
score += 0.05
evidence.append("Binärer Kontextsensor bevorzugt für Zusatzkontext.")
+ outdoor_tokens = {"aussen", "außen", "outdoor", "garten", "terrasse", "balkon"}
+ if entity_tokens.intersection(outdoor_tokens) and entity.device_class in {
+ "illuminance",
+ "humidity",
+ "temperature",
+ }:
+ score += 0.1
+ evidence.append("Außenmesswert ist oft als übergreifender Kontext relevant.")
return round(min(score, 1.0), 4), evidence
diff --git a/app/api/v1/actuators.py b/app/api/v1/actuators.py
index 374e0c4..44457af 100644
--- a/app/api/v1/actuators.py
+++ b/app/api/v1/actuators.py
@@ -14,19 +14,6 @@ from app.ha.models import HaEntitySummary
from app.ha.reader import HaReader
router = APIRouter(prefix="/v1/actuators", tags=["actuators"])
-_MANUAL_CONTEXT_DOMAINS = frozenset({
- "binary_sensor",
- "climate",
- "cover",
- "device_tracker",
- "fan",
- "humidifier",
- "light",
- "person",
- "sensor",
- "switch",
- "weather",
-})
class ConfigureActuatorRequest(BaseModel):
@@ -62,19 +49,16 @@ def discover_actuators(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaE
@router.get("/context-options", response_model=list[HaEntitySummary])
-def context_options(ha_reader: HaReader = Depends(get_ha_reader)) -> list[HaEntitySummary]:
- return sorted(
- [
- entity
- for entity in ha_reader.read_entities()
- if entity.domain in _MANUAL_CONTEXT_DOMAINS
- ],
- key=lambda entity: (
- entity.area_name or "",
- entity.friendly_name or entity.entity_id,
- entity.entity_id,
- ),
- )
+def context_options(
+ request: Request,
+ actuator_entity_id: str | None = Query(default=None, pattern=r"^[a-z0-9_]+\.[a-z0-9_]+$"),
+) -> list[HaEntitySummary]:
+ if actuator_entity_id is None:
+ return []
+ try:
+ return _service(request).suggest_context_options(actuator_entity_id)
+ except KeyError as exc:
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("", response_model=list[ActuatorRecord])
diff --git a/app/main.py b/app/main.py
index 90fd207..43d1387 100644
--- a/app/main.py
+++ b/app/main.py
@@ -100,7 +100,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.3",
+ version="0.7.4",
lifespan=lifespan,
)
app.state.settings = load_settings()
diff --git a/app/static/index.html b/app/static/index.html
index d6c4219..6247dc2 100644
--- a/app/static/index.html
+++ b/app/static/index.html
@@ -51,6 +51,10 @@
.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; }
+ .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; }
+ optgroup { color:#cfe0ec; background:#101820; }
code { color:#cfe0ec; overflow-wrap:anywhere; }
@media (max-width: 760px) {
header { padding:18px 14px; }
@@ -123,6 +127,23 @@
+
+
+
+
+
+
+
+
+
+