Compare commits

..

1 Commits

Author SHA1 Message Date
9ff005f089 Add room management settings
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-07-26 22:44:56 +02:00
9 changed files with 647 additions and 5 deletions

View File

@@ -1,5 +1,16 @@
# Changelog
## 1.7.6 - 2026-07-26
- Einstellungen um eine Raumverwaltung erweitert: Räume zeigen Aktoren,
aktive/optionale/nicht nötige Sensoren und lesbare Vorhersage-Regeln in
einer gemeinsamen Ansicht.
- Neue API `/v1/actuators/settings/rooms` liefert kompakte Verwaltungsdaten
für Raumkarten, Sensorvorschläge, Aktoren und noch nicht verwaltete
Vorschläge.
- Licht-/Schalter-Zuordnung darf bei eindeutigem Tür-/Öffnungskontext ohne
numerischen Helligkeitssensor arbeiten, z. B. Tür auf -> Licht an und Tür zu
-> Licht aus.
## 1.7.5 - 2026-07-26
- Dashboard-Sprachumschaltung übersetzt jetzt auch dynamisch gerenderte
Status-, Discovery-, Detail-, Listen-, Button- und Aufklapptexte.

View File

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

View File

@@ -546,6 +546,25 @@ class ActuatorReconciliationService:
if candidate.auto_accepted
][: _MAX_CONTEXT_SELECTIONS]
top_contexts = [candidate.entity_id for candidate in accepted_contexts]
if (
top_numeric is not None
and actuator.domain in {"light", "switch"}
and any(
(candidate.device_class or "") in {"door", "garage_door", "opening", "window"}
for candidate in accepted_contexts
)
):
return AssignmentSelection(
selected_numeric_entity_id=None,
selected_context_entity_ids=top_contexts,
source=AssignmentSource.AUTOMATIC,
confidence=max(candidate.confidence for candidate in accepted_contexts),
review_required=False,
reason=(
"Tür-/Öffnungskontext automatisch erkannt. Für diese "
"direkte Schaltlogik ist kein Helligkeitssensor erforderlich."
),
)
if top_numeric is None:
if accepted_contexts:
return AssignmentSelection(

View File

@@ -13,6 +13,8 @@ from app.actuators.lifecycle import ActuatorReconciliationService
from app.actuators.models import (
ActuatorRecord,
AnomalyEvent,
AssignmentCandidate,
BehaviorPattern,
FeedbackKind,
ReconciliationState,
SensorWeightGroup,
@@ -177,6 +179,49 @@ class AnomalyOverview(BaseModel):
anomalies: list[AnomalyEvent] = Field(default_factory=list)
class RoomManagementSensor(BaseModel):
entity_id: str
domain: str
role: str
category: str
friendly_name: str | None = None
device_class: str | None = None
state: str | None = None
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
active: bool = False
optional: bool = False
not_required: bool = False
reason: str
class RoomManagementActuator(BaseModel):
actuator_entity_id: str
friendly_name: str | None = None
domain: str
behavior_mode: str
behavior_status: str
lifecycle_status: str
sample_count: int = 0
selected_numeric_entity_id: str | None = None
selected_context_entity_ids: list[str] = Field(default_factory=list)
sensors: list[RoomManagementSensor] = Field(default_factory=list)
prediction_rules: list[str] = Field(default_factory=list)
management_hint: str
class RoomManagementGroup(BaseModel):
room: str
actuator_count: int
sensors: list[RoomManagementSensor] = Field(default_factory=list)
actuators: list[RoomManagementActuator] = Field(default_factory=list)
prediction_rules: list[str] = Field(default_factory=list)
class RoomManagementOverview(BaseModel):
rooms: list[RoomManagementGroup] = Field(default_factory=list)
unmanaged_actuators: list[ActuatorSuggestion] = Field(default_factory=list)
@router.get("/discovery", response_model=list[HaEntitySummary])
def discover_actuators(
request: Request,
@@ -444,6 +489,96 @@ def list_anomalies(request: Request) -> list[AnomalyOverview]:
return overview
@router.get("/settings/rooms", response_model=RoomManagementOverview)
def room_management_overview(request: Request) -> RoomManagementOverview:
service = _service(request)
records = service.list_configured()
try:
entities = {entity.entity_id: entity for entity in service._ha_reader.read_entities()}
except Exception:
entities = _load_cached_entity_map(
request,
{
entity_id
for record in records
for entity_id in [
record.actuator_entity_id,
record.assignment.selected_numeric_entity_id,
*record.assignment.selected_context_entity_ids,
*[candidate.entity_id for candidate in record.numeric_candidates[:8]],
*[candidate.entity_id for candidate in record.context_candidates[:12]],
]
if entity_id
},
)
configured_ids = {record.actuator_entity_id for record in records}
rooms: dict[str, RoomManagementGroup] = {}
for record in records:
actuator = entities.get(record.actuator_entity_id)
room = (
actuator.area_name
if actuator is not None and actuator.area_name
else _candidate_room(record)
) or "Ohne Raum"
selected_context_ids = set(record.assignment.selected_context_entity_ids)
selected_numeric_id = record.assignment.selected_numeric_entity_id
selected_ids = {selected_numeric_id, *selected_context_ids} - {None}
ranked_candidates = _rank_management_candidates(record)
has_opening_context = any(
candidate.entity_id in selected_context_ids
and (candidate.device_class or "") in {"door", "garage_door", "opening", "window"}
for candidate in ranked_candidates
)
sensors = [
_management_sensor(
candidate,
entities.get(candidate.entity_id),
active=candidate.entity_id in selected_ids,
optional=(
candidate.role is EntityRole.MEASUREMENT
and candidate.entity_id != selected_numeric_id
),
not_required=(
record.actuator_entity_id.startswith(("light.", "switch."))
and has_opening_context
and (candidate.device_class or "") == "illuminance"
),
)
for candidate in ranked_candidates[:12]
]
actuator_group = RoomManagementActuator(
actuator_entity_id=record.actuator_entity_id,
friendly_name=actuator.friendly_name if actuator is not None else None,
domain=record.actuator_entity_id.split(".", 1)[0],
behavior_mode=record.behavior.mode.value,
behavior_status=record.behavior.status.value,
lifecycle_status=record.lifecycle.status.value,
sample_count=record.behavior.sample_count,
selected_numeric_entity_id=selected_numeric_id,
selected_context_entity_ids=record.assignment.selected_context_entity_ids,
sensors=sensors,
prediction_rules=_prediction_rule_lines(record, ranked_candidates),
management_hint=_management_hint(record, has_opening_context),
)
if room not in rooms:
rooms[room] = RoomManagementGroup(room=room, actuator_count=0)
rooms[room].actuators.append(actuator_group)
rooms[room].actuator_count += 1
rooms[room].prediction_rules = _unique_lines([
*rooms[room].prediction_rules,
*actuator_group.prediction_rules,
])[:8]
rooms[room].sensors = _merge_room_sensors(rooms[room].sensors, sensors)
unmanaged = [
suggestion for suggestion in suggest_actuators(request, service._ha_reader)
if suggestion.entity_id not in configured_ids
][:10]
return RoomManagementOverview(
rooms=sorted(rooms.values(), key=lambda item: item.room.lower()),
unmanaged_actuators=unmanaged,
)
@router.get("/backup/export", response_model=BackupPayload)
def export_backup(request: Request) -> BackupPayload:
store = getattr(request.app.state, "actuator_store", None)
@@ -922,6 +1057,220 @@ def _validate_weight_payload(payload: WeightOverrideRequest) -> None:
raise ValueError(f"Ungültige Entity-ID in Gruppe {group.name}: {entity_id}")
def _candidate_room(record: ActuatorRecord) -> str | None:
for candidate in [*record.context_candidates, *record.numeric_candidates]:
if candidate.area_name:
return candidate.area_name
return None
def _rank_management_candidates(record: ActuatorRecord) -> list[AssignmentCandidate]:
selected_ids = {
entity_id
for entity_id in [
record.assignment.selected_numeric_entity_id,
*record.assignment.selected_context_entity_ids,
]
if entity_id
}
candidates = {
candidate.entity_id: candidate
for candidate in [*record.context_candidates, *record.numeric_candidates]
}
ranked = sorted(
candidates.values(),
key=lambda item: (
item.entity_id not in selected_ids,
_management_sort_group(item),
-item.confidence,
-item.score,
item.entity_id,
),
)
return ranked
def _management_sort_group(candidate: AssignmentCandidate) -> str:
device_class = candidate.device_class or ""
if device_class in {"door", "garage_door", "opening", "window"}:
return "01_opening"
if device_class in {"motion", "occupancy", "presence"}:
return "02_presence"
if device_class == "illuminance":
return "03_brightness"
if device_class in {"humidity", "moisture"}:
return "04_humidity"
if candidate.role is EntityRole.MEASUREMENT:
return "08_measurement"
return f"20_{candidate.domain}_{device_class}"
def _management_sensor(
candidate: AssignmentCandidate,
entity: HaEntitySummary | None,
*,
active: bool,
optional: bool,
not_required: bool,
) -> RoomManagementSensor:
if not_required:
reason = "Nicht nötig, weil ein Tür-/Öffnungskontakt die Lichtlogik direkt erklärt."
elif active:
reason = "Wird aktuell für Lernen und Vorhersage verwendet."
elif optional:
reason = "Optionaler Messwert; nur verwenden, wenn Helligkeit oder Verbrauch wirklich steuern soll."
else:
reason = ", ".join(candidate.evidence[:2]) or "Naheliegender Kontext aus Raum, Gerät oder Namen."
return RoomManagementSensor(
entity_id=candidate.entity_id,
domain=candidate.domain,
role=candidate.role.value,
category=_sensor_category_label(candidate),
friendly_name=candidate.friendly_name,
device_class=candidate.device_class,
state=entity.state if entity is not None else None,
confidence=candidate.confidence,
active=active,
optional=optional,
not_required=not_required,
reason=reason,
)
def _sensor_category_label(candidate: AssignmentCandidate) -> str:
device_class = candidate.device_class or ""
if device_class in {"door", "garage_door", "opening", "window"}:
return "Tür/Fenster"
if device_class in {"motion", "occupancy", "presence"}:
return "Präsenz"
if device_class == "illuminance":
return "Helligkeit"
if device_class in {"humidity", "moisture"}:
return "Luftfeuchtigkeit"
if device_class in {"power", "energy", "current", "voltage"}:
return "Energie"
if candidate.domain in {"cover"}:
return "Rollo/Cover"
if candidate.domain in {"zone", "person", "device_tracker"}:
return "Zone/Person"
return "Kontext"
def _merge_room_sensors(
existing: list[RoomManagementSensor],
incoming: list[RoomManagementSensor],
) -> list[RoomManagementSensor]:
by_id = {sensor.entity_id: sensor for sensor in existing}
for sensor in incoming:
current = by_id.get(sensor.entity_id)
if current is None:
by_id[sensor.entity_id] = sensor
continue
by_id[sensor.entity_id] = current.model_copy(
update={
"active": current.active or sensor.active,
"optional": current.optional and sensor.optional,
"not_required": current.not_required and sensor.not_required,
"confidence": max(current.confidence, sensor.confidence),
}
)
return sorted(
by_id.values(),
key=lambda item: (
not item.active,
item.not_required,
item.category,
item.friendly_name or item.entity_id,
),
)[:18]
def _prediction_rule_lines(
record: ActuatorRecord,
candidates: list[AssignmentCandidate],
) -> list[str]:
lines = _pattern_rule_lines(record.behavior.patterns)
if lines:
return lines[:8]
selected_contexts = [
candidate
for candidate in candidates
if candidate.entity_id in set(record.assignment.selected_context_entity_ids)
]
result: list[str] = []
for candidate in selected_contexts:
label = candidate.friendly_name or candidate.entity_id
device_class = candidate.device_class or ""
if device_class in {"door", "garage_door", "opening", "window"}:
result.extend([
f"{label} geöffnet -> {record.actuator_entity_id} an.",
f"{label} geschlossen -> {record.actuator_entity_id} aus.",
])
elif device_class in {"motion", "occupancy", "presence"}:
result.extend([
f"{label} erkannt -> {record.actuator_entity_id} an, bei Licht bevorzugt gedimmt.",
f"{label} aus -> {record.actuator_entity_id} verzögert ausschalten.",
])
elif device_class in {"humidity", "moisture"}:
result.append(f"{label} hoch -> {record.actuator_entity_id} einschalten, bis Feuchte wieder normal ist.")
if record.assignment.selected_numeric_entity_id:
result.append(
f"{record.assignment.selected_numeric_entity_id} nur als Messwert verwenden, nicht als Pflichtsensor."
)
return _unique_lines(result)[:8] or ["Noch keine stabile Vorhersage; erst Kontext prüfen und weiter beobachten."]
def _pattern_rule_lines(patterns: list[BehaviorPattern]) -> list[str]:
buckets: dict[tuple[str, tuple[tuple[str, str], ...]], int] = {}
attrs: dict[tuple[str, tuple[tuple[str, str], ...]], dict[str, object]] = {}
for pattern in patterns[-120:]:
context = tuple(sorted(pattern.context_states.items()))
key = (pattern.target_state, context)
buckets[key] = buckets.get(key, 0) + 1
attrs[key] = pattern.target_attributes
ordered = sorted(buckets.items(), key=lambda item: (-item[1], item[0]))
lines: list[str] = []
for (target_state, context), count in ordered[:8]:
conditions = ", ".join(f"{entity}={state}" for entity, state in context[:3])
if not conditions:
conditions = "aktueller Zeit-/Nutzungskontext passt"
attr_text = _attribute_text(attrs.get((target_state, context), {}))
lines.append(f"{conditions} -> {target_state}{attr_text} ({count}x gelernt).")
return lines
def _attribute_text(attributes: dict[str, object]) -> str:
if not attributes:
return ""
brightness = attributes.get("brightness")
if isinstance(brightness, int | float):
percent = round(max(0, min(255, float(brightness))) / 255 * 100)
return f", Helligkeit {percent} %"
return ""
def _management_hint(record: ActuatorRecord, has_opening_context: bool) -> str:
if has_opening_context and record.actuator_entity_id.startswith(("light.", "switch.")):
return "Direkte Türlogik: kein Helligkeitssensor nötig, Sensor und Aktor reichen."
if record.behavior.activation_ready:
return "Regeln sind lernbereit; vor Aktivierung Vorhersagen prüfen."
if record.assignment.review_required:
return "Kontext prüfen: Vorschläge übernehmen oder unpassende Sensoren entfernen."
return "Weiter beobachten, bis genug eindeutige Schaltbeispiele vorhanden sind."
def _unique_lines(lines: list[str]) -> list[str]:
seen: set[str] = set()
result: list[str] = []
for line in lines:
normalized = line.strip()
if not normalized or normalized in seen:
continue
seen.add(normalized)
result.append(normalized)
return result
def _validate_simulation_payload(payload: SimulationRequest) -> None:
for entity_id in [
*payload.sensor_states.keys(),

View File

@@ -100,6 +100,14 @@
.card-title .chip { flex:0 1 auto; white-space:normal; text-align:center; }
.actuator-card strong { overflow-wrap:anywhere; word-break:break-word; }
.entity-id { overflow-wrap:anywhere; word-break:break-word; font-weight:800; }
.room-board { display:grid; gap:10px; margin-top:12px; }
.room-card { background:#121922; border:1px solid var(--border); border-radius:8px; padding:10px; min-width:0; }
.room-card header { padding:0; border:0; background:transparent; display:flex; justify-content:space-between; gap:10px; flex-wrap:wrap; }
.sensor-strip { display:flex; flex-wrap:wrap; gap:6px; margin:8px 0; }
.sensor-pill { border:1px solid var(--border); border-radius:8px; padding:6px 8px; background:#17202b; max-width:100%; overflow-wrap:anywhere; }
.sensor-pill.active { border-color:var(--ok); }
.sensor-pill.optional { border-color:var(--complement); }
.sensor-pill.not-required { opacity:.72; border-style:dashed; }
.metric-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:6px; margin:8px 0; }
.metric { background:var(--panel-soft); border:1px solid var(--border); border-radius:8px; padding:8px; min-width:0; overflow-wrap:anywhere; word-break:break-word; }
.metric strong { display:block; margin-bottom:4px; color:#cfe0ec; font-size:.84rem; }
@@ -246,8 +254,9 @@
<div class="panel-title">
<div>
<h2>Einstellungen</h2>
<p class="muted">Sprache und Standardwerte für die Bedienoberfläche.</p>
<p class="muted">Sprache, Räume, Sensoren, Aktoren und Vorhersagen an einem Ort.</p>
</div>
<button class="secondary compact" onclick="loadRoomManagement(true)">Räume laden</button>
</div>
<div class="grid-two">
<div>
@@ -257,6 +266,13 @@
<option value="en">English</option>
</select>
</div>
<div>
<label for="room-management-filter">Raum suchen</label>
<input id="room-management-filter" placeholder="z. B. Abstellkammer, Wohnbereich" oninput="renderRoomManagement()">
</div>
</div>
<div id="room-management" class="room-board">
<div class="empty-state">Lade die Raumverwaltung, um Sensoren, Aktoren und Vorhersagen zusammen zu pflegen.</div>
</div>
</section>
</main>
@@ -276,6 +292,7 @@ let cachedEntities = null;
let cachedDiscovery = null;
let cachedSystemOverview = null;
let cachedDashboardOverview = null;
let cachedRoomManagement = null;
let cachedDetailHtml = new Map();
let discoveryLoadPromise = null;
let overviewLoadPromise = null;
@@ -334,8 +351,12 @@ const I18N = {
check_status: "Status prüfen",
checking: "Prüfung läuft ...",
settings: "Einstellungen",
settings_hint: "Sprache und Standardwerte für die Bedienoberfläche.",
settings_hint: "Sprache, Räume, Sensoren, Aktoren und Vorhersagen an einem Ort.",
language: "Sprache",
load_rooms: "Räume laden",
room_search: "Raum suchen",
room_search_placeholder: "z. B. Abstellkammer, Wohnbereich",
room_management_empty: "Lade die Raumverwaltung, um Sensoren, Aktoren und Vorhersagen zusammen zu pflegen.",
no_prediction: "Keine fällige Aktion",
open: "offen",
no_area: "Ohne Bereich",
@@ -469,8 +490,12 @@ const I18N = {
check_status: "Check status",
checking: "Checking ...",
settings: "Settings",
settings_hint: "Language and UI defaults.",
settings_hint: "Language, rooms, sensors, actuators, and predictions in one place.",
language: "Language",
load_rooms: "Load rooms",
room_search: "Search room",
room_search_placeholder: "e.g. storage room, living area",
room_management_empty: "Load room management to maintain sensors, actuators, and predictions together.",
no_prediction: "No due action",
open: "open",
no_area: "No area",
@@ -767,6 +792,7 @@ function showView(viewId) {
if (cachedDiscovery) renderActuatorDiscovery();
} else if (viewId === "settings") {
syncSettingsView();
void loadRoomManagement();
}
document.getElementById(viewId)?.scrollIntoView({behavior: "smooth", block: "start"});
}
@@ -790,6 +816,9 @@ function setLanguage(language) {
function syncSettingsView() {
const select = document.getElementById("language-select");
if (select) select.value = uiLang;
if (cachedRoomManagement) {
renderRoomManagement();
}
}
function translate(group, value, fallback = "") {
@@ -939,6 +968,14 @@ function applyStaticTranslations() {
setText("#settings h2", "settings");
setText("#settings .muted", "settings_hint");
setText("label[for='language-select']", "language");
const roomLoadButton = document.querySelector("#settings .panel-title button");
if (roomLoadButton) roomLoadButton.textContent = ui("load_rooms");
setText("label[for='room-management-filter']", "room_search");
setPlaceholder("#room-management-filter", "room_search_placeholder");
const roomManagement = document.getElementById("room-management");
if (roomManagement && !cachedRoomManagement) {
roomManagement.innerHTML = `<div class="empty-state">${escapeHtml(ui("room_management_empty"))}</div>`;
}
localizeFragment(document);
}
@@ -960,6 +997,7 @@ function invalidateDashboardCache() {
cachedDiscovery = null;
cachedDashboardOverview = null;
cachedSystemOverview = null;
cachedRoomManagement = null;
cachedDetailHtml.clear();
}
@@ -1343,6 +1381,168 @@ function renderJobQueue(jobs) {
localizeFragment(jobsBox);
}
async function loadRoomManagement(force = false) {
const box = document.getElementById("room-management");
if (!box) return;
if (cachedRoomManagement && !force) {
renderRoomManagement();
return;
}
box.innerHTML = `<p class='muted'>${escapeHtml(uiLang === "en" ? "Loading rooms ..." : "Räume werden geladen ...")}</p>`;
try {
cachedRoomManagement = await api("v1/actuators/settings/rooms");
renderRoomManagement();
} catch (error) {
box.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
}
localizeFragment(box);
}
function renderRoomManagement() {
const box = document.getElementById("room-management");
if (!box) return;
const overview = cachedRoomManagement;
if (!overview) {
box.innerHTML = `<div class="empty-state">${escapeHtml(ui("room_management_empty"))}</div>`;
return;
}
const query = normalizedSearch(document.getElementById("room-management-filter")?.value || "");
const rooms = (overview.rooms || []).filter(room =>
!query
|| normalizedSearch(room.room).includes(query)
|| (room.actuators || []).some(actuator =>
matchesSearch({
entity_id: actuator.actuator_entity_id,
friendly_name: actuator.friendly_name,
area_name: room.room,
}, query)
)
);
const unmanaged = overview.unmanaged_actuators || [];
box.innerHTML = `
${rooms.length ? rooms.map(room => `
<article class="room-card">
<header>
<div>
<h3>${escapeHtml(room.room)}</h3>
<p class="muted">${escapeHtml(room.actuator_count)} ${escapeHtml(uiLang === "en" ? "actuator(s)" : "Aktor(en)")}</p>
</div>
<span class="chip">${escapeHtml((room.sensors || []).filter(sensor => sensor.active).length)} ${escapeHtml(uiLang === "en" ? "active sensors" : "aktive Sensoren")}</span>
</header>
<div class="sensor-strip">
${(room.sensors || []).slice(0, 12).map(sensor => sensorPill(sensor)).join("") || `<span class="muted">${escapeHtml(uiLang === "en" ? "No sensors selected yet." : "Noch keine Sensoren ausgewählt.")}</span>`}
</div>
<div class="decision-list">
${(room.prediction_rules || []).slice(0, 5).map(rule => `<div class="decision-row">${escapeHtml(rule)}</div>`).join("")}
</div>
<div class="card-list">
${(room.actuators || []).map(actuator => roomActuatorCard(actuator)).join("")}
</div>
</article>
`).join("") : `<div class="empty-state">${escapeHtml(uiLang === "en" ? "No matching rooms." : "Keine passenden Räume.")}</div>`}
${unmanaged.length ? `
<article class="room-card">
<header>
<h3>${escapeHtml(uiLang === "en" ? "Unmanaged suggestions" : "Noch nicht verwaltete Vorschläge")}</h3>
<span class="chip">${escapeHtml(unmanaged.length)}</span>
</header>
<div class="card-list">
${unmanaged.slice(0, 6).map(item => `
<article class="actuator-card">
<div class="card-title">
<div>
<strong>${escapeHtml(item.friendly_name || item.entity_id)}</strong>
<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)}')">${escapeHtml(uiLang === "en" ? "Manage" : "Verwalten")}</button>
</article>
`).join("")}
</div>
</article>
` : ""}
`;
localizeFragment(box);
}
function sensorPill(sensor) {
const classes = [
"sensor-pill",
sensor.active ? "active" : "",
sensor.optional ? "optional" : "",
sensor.not_required ? "not-required" : "",
].filter(Boolean).join(" ");
const state = sensor.state == null ? "" : ` · ${sensor.state}`;
const status = sensor.not_required
? (uiLang === "en" ? "not needed" : "nicht nötig")
: sensor.active
? (uiLang === "en" ? "active" : "aktiv")
: sensor.optional
? (uiLang === "en" ? "optional" : "optional")
: (uiLang === "en" ? "suggested" : "Vorschlag");
return `
<span class="${classes}" title="${escapeHtml(sensor.reason)}">
<strong>${escapeHtml(sensor.friendly_name || sensor.entity_id)}</strong>
<span class="muted"> · ${escapeHtml(sensor.category)} · ${escapeHtml(status)}${escapeHtml(state)}</span>
</span>
`;
}
function roomActuatorCard(actuator) {
const activeSensorIds = (actuator.sensors || [])
.filter(sensor => sensor.active && !sensor.not_required)
.map(sensor => sensor.entity_id);
const selectedContextIds = (actuator.sensors || [])
.filter(sensor => sensor.active && sensor.entity_id !== actuator.selected_numeric_entity_id)
.map(sensor => sensor.entity_id);
return `
<article class="actuator-card">
<div class="card-title">
<div>
<strong>${escapeHtml(actuator.friendly_name || actuator.actuator_entity_id)}</strong>
<div class="entity-id">${escapeHtml(actuator.actuator_entity_id)}</div>
<div class="muted">${escapeHtml(behaviorLabel(actuator))} · ${escapeHtml(lifecycleLabel(actuator))}</div>
</div>
<span class="chip">${escapeHtml(actuator.sample_count)} ${escapeHtml(uiLang === "en" ? "actions" : "Handlungen")}</span>
</div>
<p class="muted">${escapeHtml(actuator.management_hint)}</p>
<div class="sensor-strip">
${(actuator.sensors || []).slice(0, 8).map(sensor => sensorPill(sensor)).join("")}
</div>
<div class="decision-list">
${(actuator.prediction_rules || []).slice(0, 4).map(rule => `<div class="decision-row">${escapeHtml(rule)}</div>`).join("")}
</div>
<div class="actions">
<button class="secondary" onclick="showActuator('${escapeHtml(actuator.actuator_entity_id)}')">${escapeHtml(uiLang === "en" ? "Open details" : "Details öffnen")}</button>
<button onclick="saveRoomAssignment('${escapeHtml(actuator.actuator_entity_id)}', '${escapeHtml(actuator.selected_numeric_entity_id || "")}', '${escapeHtml(selectedContextIds.join(","))}')">${escapeHtml(uiLang === "en" ? "Save selection" : "Auswahl speichern")}</button>
</div>
<p class="muted">${escapeHtml(uiLang === "en" ? "Current selection:" : "Aktuelle Auswahl:")} ${escapeHtml(activeSensorIds.join(", ") || (uiLang === "en" ? "none" : "keine"))}</p>
</article>
`;
}
async function saveRoomAssignment(actuatorId, numericEntityId, contextCsv) {
const contextEntityIds = contextCsv.split(",").map(item => item.trim()).filter(Boolean);
try {
await api(`v1/actuators/${encodeURIComponent(actuatorId)}/assignment`, {
method: "POST",
body: JSON.stringify({
numeric_entity_id: numericEntityId || null,
context_entity_ids: contextEntityIds,
note: "In der Raumverwaltung gespeichert",
}),
});
invalidateDashboardCache();
cachedRoomManagement = null;
await loadRoomManagement(true);
} catch (error) {
alert(error.message);
}
}
async function loadSummaryData() {
const actuators = await api("v1/actuators/summary");
cachedActuators = actuators;

View File

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

View File

@@ -141,6 +141,46 @@ def test_reconciliation_auto_assigns_and_trains_numeric_model(tmp_path: Path) ->
assert "binary_sensor.abstellkammer_motion" not in artifact.supported_sensors
def test_light_with_opening_context_does_not_require_brightness_sensor(tmp_path: Path) -> None:
start = datetime.now(timezone.utc) - timedelta(days=1)
entities = [
HaEntitySummary(
entity_id="light.abstellkammer",
domain="light",
friendly_name="Abstellkammer Licht",
area_name="Abstellkammer",
),
HaEntitySummary(
entity_id="sensor.abstellkammer_illuminance",
domain="sensor",
device_class="illuminance",
state_class="measurement",
unit_of_measurement="lx",
friendly_name="Abstellkammer Helligkeit",
area_name="Abstellkammer",
),
HaEntitySummary(
entity_id="binary_sensor.abstellkammer_tuer",
domain="binary_sensor",
device_class="door",
friendly_name="Tür Abstellkammer",
area_name="Abstellkammer",
),
]
service = _service(
tmp_path,
entities,
{"sensor.abstellkammer_illuminance": _points(8, start, 10.0)},
)
record = service.configure_actuator("light.abstellkammer")
assert record.assignment.selected_numeric_entity_id is None
assert record.assignment.selected_context_entity_ids == ["binary_sensor.abstellkammer_tuer"]
assert record.assignment.review_required is False
assert "kein Helligkeitssensor erforderlich" in record.assignment.reason
def test_reconciliation_rejects_ambiguous_numeric_mapping(tmp_path: Path) -> None:
start = datetime(2026, 6, 1, tzinfo=timezone.utc)
entities = [

View File

@@ -623,6 +623,25 @@ def test_dashboard_system_and_start_do_not_materialize_entity_cache(
assert start_response.json()["actuators"][0]["friendly_name"] == "Abstellkammer Licht"
def test_room_management_overview_groups_actuators_with_sensors_and_rules(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)
client.post("/v1/actuators", json={"actuator_entity_id": "light.abstellkammer"})
response = client.get("/v1/actuators/settings/rooms")
assert response.status_code == 200
payload = response.json()
room = payload["rooms"][0]
assert room["room"] == "Abstellkammer"
assert room["actuator_count"] == 1
actuator = room["actuators"][0]
assert actuator["actuator_entity_id"] == "light.abstellkammer"
assert actuator["sensors"]
assert actuator["prediction_rules"]
assert any(sensor["entity_id"] == "binary_sensor.abstellkammer_motion" for sensor in room["sensors"])
def test_actuator_detail_uses_compact_payload(tmp_path: Path) -> None:
with TestClient(app) as client:
_install_service(tmp_path)

View File

@@ -22,6 +22,10 @@ def test_dashboard_is_served_at_root() -> None:
assert "Ohne deine spätere Freigabe wird nichts geschaltet" not in response.text
assert "Du wählst keine Sensoren und erstellst keine Regeln" not in response.text
assert "Freigabestatus" in response.text
assert "Sprache, Räume, Sensoren, Aktoren und Vorhersagen an einem Ort." in response.text
assert "room-management" in response.text
assert 'api("v1/actuators/settings/rooms")' in response.text
assert "Auswahl speichern" in response.text
assert "SillyHome übernehmen lassen" in response.text
assert "Passende Home-Assistant-Automationen" in response.text
assert "Pausieren" in response.text