Compare commits

...

2 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
954c4511a7 Fix complete dashboard i18n refresh
Some checks failed
quality / test (3.11) (push) Has been cancelled
quality / test (3.13) (push) Has been cancelled
2026-07-26 22:25:13 +02:00
9 changed files with 929 additions and 12 deletions

View File

@@ -1,5 +1,24 @@
# 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.
- Aufklapp-Hinweise (`expand`/`collapse`) kommen nicht mehr fest aus CSS auf
Deutsch, sondern werden pro Sprache gesetzt.
- Detail-Cache wird beim Sprachwechsel geleert, damit keine alten deutschen
HTML-Fragmente in der englischen Oberfläche sichtbar bleiben.
## 1.7.4 - 2026-07-26
- Dashboard-Sprachumschaltung aktualisiert statische Texte, Labels,
Platzhalter und wichtige Laufzeittexte direkt beim Wechsel.

View File

@@ -1,5 +1,5 @@
name: SillyHome Next
version: "1.7.4"
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

@@ -65,10 +65,10 @@
.group-panel > summary::-webkit-details-marker { display:none; }
details.collapsible > summary::after,
.manual-context > summary::after,
.group-panel > summary::after { content:"aufklappen"; color:#9fb0be; font-weight:600; font-size:.86rem; }
.group-panel > summary::after { content:attr(data-closed-label); color:#9fb0be; font-weight:600; font-size:.86rem; }
details[open].collapsible > summary::after,
.manual-context[open] > summary::after,
.group-panel[open] > summary::after { content:"zuklappen"; }
.group-panel[open] > summary::after { content:attr(data-open-label); }
.steps { display:grid; grid-template-columns:repeat(auto-fit,minmax(190px,1fr)); gap:10px; margin-top:12px; }
.step { background:#121922; border:1px solid var(--border); border-radius:8px; padding:10px; }
.step-number { display:inline-grid; place-items:center; width:28px; height:28px; border-radius:8px; background:var(--complement); color:#03151d; font-weight:900; margin-bottom:8px; }
@@ -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",
@@ -345,6 +366,8 @@ const I18N = {
unavailable_start: "Startdaten sind gerade nicht verfügbar.",
system_loading: "Systemübersicht lädt ...",
system_delayed: "Systemübersicht verzögert",
expand: "aufklappen",
collapse: "zuklappen",
context_detected: "Kontext erkannt",
active_approved: "aktiv freigegeben",
shadow_prediction: "Prüfmodus mit Vorhersage",
@@ -467,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",
@@ -478,6 +505,8 @@ const I18N = {
unavailable_start: "Start data is currently unavailable.",
system_loading: "Loading system overview ...",
system_delayed: "System overview delayed",
expand: "expand",
collapse: "collapse",
context_detected: "Context detected",
active_approved: "actively approved",
shadow_prediction: "Review mode with prediction",
@@ -554,6 +583,182 @@ const I18N = {
},
},
};
const DE_TO_EN_TEXT = new Map(Object.entries({
"Aktiv / Prüfmodus": "Active / review mode",
"Aktive Gewichtung": "Active weight",
"Aktoren": "Actuators",
"Aktualisieren": "Refresh",
"Aktualisiert.": "Refreshed.",
"Aktuelle Blocker:": "Current blockers:",
"Aktuelle Situation auswerten": "Evaluate current situation",
"Aktuell ist kein gelerntes Handlungsmuster fällig.": "No learned action pattern is due right now.",
"Alle relevanten Vorschläge": "All relevant suggestions",
"Als Gruppe speichern": "Save as group",
"Anderes Gerät": "Another device",
"Annahmen": "Assumptions",
"Anomalien": "Anomalies",
"Anomalien offen": "anomalies open",
"Aktor-Simulation": "Actuator simulation",
"Aufgabenliste": "Task list",
"Automationen neu suchen": "Find automations again",
"Automatische Gewichtsanpassungen": "Automatic weight adjustments",
"Automatische Relevanz": "Automatic relevance",
"Bedienoberfläche.": "user interface.",
"Bereit.": "Ready.",
"Bestes Szenario": "Best scenario",
"Bestes Szenario berechnen": "Calculate best scenario",
"Betriebsart:": "Mode:",
"Beitragsfaktoren": "Contributing factors",
"Cache aktuell mit": "Cache current with",
"Cache wird nach Discovery aufgebaut": "Cache will be built after discovery",
"Cache-Zeitpunkt": "Cache time",
"Cooldown Sekunden": "Cooldown seconds",
"Dashboard bereit in": "Dashboard ready in",
"Dashboard bleibt bedienbar.": "The dashboard remains usable.",
"Dauer:": "Duration:",
"Davon eindeutig geregelt:": "Clearly regulated:",
"Details öffnen": "Open details",
"Diese Kontext-Auswahl speichern": "Save this context selection",
"Discovery-Gruppen": "Discovery groups",
"Entfernen": "Remove",
"Entscheidungsakte": "Decision record",
"Entity-IDs der Gruppe": "Group entity IDs",
"Entity-IDs manuell ergänzen": "Add entity IDs manually",
"Ergebnis:": "Result:",
"Erkannt:": "Detected:",
"Erkannte HA-Automationen:": "Detected HA automations:",
"Erneut versuchen: Aktion im Dashboard noch einmal starten; der nächste Lauf schreibt einen neuen Eintrag.": "Try again: start the dashboard action again; the next run will create a new entry.",
"Freigabe": "Approval",
"Freigabebereit": "Ready for approval",
"Freigabestatus:": "Approval status:",
"Freigabestufe": "Approval stage",
"Für die Simulation müssen zuerst Kontextsensoren ausgewählt sein.": "Select context sensors before running a simulation.",
"Geladene Startdaten": "Loaded start data",
"Gelernte Handlungen": "Learned actions",
"Gelernt / Wartet": "Learned / waiting",
"Gerät auswählen": "Select device",
"Geräteliste konnte nicht geladen werden:": "Device list could not be loaded:",
"Geräteliste lädt im Hintergrund ...": "Device list loading in the background ...",
"Geräteliste wird geladen ...": "Device list is loading ...",
"Gewichtung": "Weight",
"Gewichtung korrigieren": "Adjust weight",
"Gewichtung übernehmen": "Apply weight",
"Gewichtung speichern": "Save weight",
"Gewichtungen speichern": "Save weights",
"Gruppen-Gewicht in %": "Group weight in %",
"Gruppen-Gewichtung": "Group weighting",
"Gruppenname": "Group name",
"HA-Automationen pausieren": "pause HA automations",
"HA-Automationen pausiert lassen": "leave HA automations paused",
"HA-Automationen fortsetzen": "resume HA automations",
"Handlungen": "Actions",
"Job p95": "Job p95",
"Jobs aktiv": "Active jobs",
"Kategorie": "Category",
"Kategorien": "categories",
"Keine Annahmen gespeichert.": "No assumptions stored.",
"Keine aktiven Automation-Konflikte erkannt.": "No active automation conflicts detected.",
"Keine eindeutig passende HA-Automation gefunden.": "No clearly matching HA automation found.",
"Keine gesicherten Punkte gespeichert.": "No confirmed points stored.",
"Keine lokalen Sicherheitsblocker für die aktuelle Vorhersage.": "No local safety blockers for the current prediction.",
"Keine offenen Anomalien fuer diesen Aktor.": "No open anomalies for this actuator.",
"Keine passenden Geräte gefunden": "No matching devices found",
"Keine passenden Vorschläge": "No matching suggestions",
"Keine Simulationsergebnisse.": "No simulation results.",
"Keine Unsicherheit gespeichert.": "No uncertainty stored.",
"Kein Kommentar": "No comment",
"Kein numerischen Hauptsensor verwenden": "Do not use a numeric main sensor",
"Keinen numerischen Hauptsensor verwenden": "Do not use a numeric main sensor",
"Kontext": "Context",
"Kontext selbst festlegen": "Set context manually",
"Kontext wird automatisch analysiert ...": "Context is being analyzed automatically ...",
"Kontext-Entity entfernt.": "Context entity removed.",
"Kontext-Entity ausgewählt.": "context entity selected.",
"Kontextzuordnung:": "Context assignment:",
"Kritisch": "Critical",
"Langsame Jobs": "Slow jobs",
"Langsam:": "Slow:",
"Lädt ...": "Loading ...",
"Lernbereit": "Ready to learn",
"Lernbereite Geräte": "Devices ready to learn",
"Lernen": "Learning",
"Lernfortschritt": "Learning progress",
"Lernsystem": "Learning system",
"Letzte automatische Prüfung:": "Last automatic check:",
"Letztes Training:": "Last training:",
"Manuelle Kontext-Auswahl gespeichert.": "Manual context selection saved.",
"Manuelle Sicherheitssperre aktiv": "Manual safety block active",
"Mindest-Sicherheit in %": "Minimum confidence in %",
"Noch keine Aktoren ausgewählt.": "No actuators selected yet.",
"Noch keine aktuelle Entscheidungsfaktoren berechnet.": "No current decision factors calculated yet.",
"Noch keine automatische Gewichtsanpassung.": "No automatic weight adjustment yet.",
"Noch keine Daten": "No data yet",
"Noch keine Gruppe gespeichert.": "No group saved yet.",
"Noch keine Kontext-Entity ausgewählt.": "No context entity selected yet.",
"Noch keine verwendeten Sensoren oder Zustände für eine Gewichtung ausgewählt.": "No sensors or states selected for weighting yet.",
"Noch kein geeigneter Kontext erkannt. SillyHome prüft bei neuen HA-Daten erneut.": "No suitable context detected yet. SillyHome will check again when new HA data arrives.",
"Noch kein Modell-Snapshot gespeichert.": "No model snapshot saved yet.",
"Numerische Helper": "Numeric helpers",
"Optionaler Haupt-Messsensor": "Optional main measurement sensor",
"Passende Home-Assistant-Automationen": "Matching Home Assistant automations",
"Passende Home-Assistant-Automationen neu geprüft.": "Matching Home Assistant automations checked again.",
"Prüfen": "Review",
"Prüfung läuft ...": "Checking ...",
"Rollback möglich": "Rollback available",
"Sicherheit und manuelles Gegensteuern": "Safety and manual override",
"Sicherheitsprofil speichern": "Save safety profile",
"Sicherheitsprofil gespeichert.": "Safety profile saved.",
"SillyHome parallel aktivieren": "Activate SillyHome in parallel",
"SillyHome stoppen; HA-Automationen pausiert lassen": "Stop SillyHome; leave HA automations paused",
"SillyHome stoppen und pausierte HA-Automationen fortsetzen": "Stop SillyHome and resume paused HA automations",
"SillyHome übernehmen lassen": "Let SillyHome take over",
"SillyHome übernehmen lassen und passende HA-Automationen pausieren": "Let SillyHome take over and pause matching HA automations",
"Simulation läuft ...": "Simulation running ...",
"Simulation übernommen.": "Simulation applied.",
"Simulation übernommen und Dry-run gestartet.": "Simulation applied and dry-run started.",
"Simulationsergebnis ist nicht mehr verfügbar. Bitte neu simulieren.": "Simulation result is no longer available. Please simulate again.",
"Simulierte Gewichtung in %": "Simulated weight in %",
"Simulierter Zustand": "Simulated state",
"Sensor-Gewichtung": "Sensor weighting",
"Sensor-Gewichtung gespeichert.": "Sensor weighting saved.",
"Start:": "Start:",
"Status teilweise verfügbar. Das Dashboard bleibt bedienbar.": "Status partially available. The dashboard remains usable.",
"Status wird geprüft ...": "Checking status ...",
"Statusgrund:": "Status reason:",
"System bereit": "System ready",
"Systemübersicht bereit in": "System overview ready in",
"Systemübersicht langsam:": "System overview slow:",
"Systemübersicht verzögert:": "System overview delayed:",
"Übernehmen + Dry-run starten": "Apply + start dry-run",
"Unsicherheit": "Uncertainty",
"Verwendete Sensoren/Zustände ändern": "Change used sensors/states",
"Vorhersage": "Prediction",
"Vorhersage korrekt": "Prediction correct",
"Vorhersage falsch": "Prediction wrong",
"Was SillyHome aktuell vorhersagt": "What SillyHome currently predicts",
"Wissen": "Knowledge",
"Wähle ein Gerät aus der geladenen Liste oder trage eine Entity-ID ein.": "Select a device from the loaded list or enter an entity ID.",
"Würde nach Sicherheitsprüfung schalten.": "Would switch after safety check.",
"Zeitprofile": "Time profiles",
"Zuordnung": "Assignment",
"Zuordnungssicherheit:": "Assignment confidence:",
"Zurück zur Übersicht": "Back to overview",
"Zustände": "States",
"Zustände vergleichen": "Compare states",
"aktiv": "active",
"aktiv freigegeben": "actively approved",
"automatisch erledigt": "automatic",
"bereit": "ready",
"keine": "none",
"keine Vorhersage": "no prediction",
"läuft/offen": "running/open",
"noch offen": "pending",
"offen": "open",
"pausiert": "paused",
"relevant": "relevant",
"statistisch relevanter Kandidat": "statistically relevant candidate",
"unbekannt": "unknown",
}));
let uiLang = localStorage.getItem("sillyhome.ui.language") || "de";
function jumpToSection(target) {
@@ -587,6 +792,7 @@ function showView(viewId) {
if (cachedDiscovery) renderActuatorDiscovery();
} else if (viewId === "settings") {
syncSettingsView();
void loadRoomManagement();
}
document.getElementById(viewId)?.scrollIntoView({behavior: "smooth", block: "start"});
}
@@ -595,6 +801,7 @@ function setLanguage(language) {
uiLang = I18N[language] ? language : "de";
localStorage.setItem("sillyhome.ui.language", uiLang);
document.documentElement.lang = uiLang;
cachedDetailHtml.clear();
applyStaticTranslations();
syncSettingsView();
renderActuatorSelect();
@@ -609,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 = "") {
@@ -630,7 +840,72 @@ function setPlaceholder(selector, key) {
if (element) element.placeholder = ui(key);
}
function translateText(value) {
if (uiLang !== "en") return value;
const text = String(value ?? "");
const direct = DE_TO_EN_TEXT.get(text.trim());
if (direct) {
return text.replace(text.trim(), direct);
}
let translated = text;
const replacements = [...DE_TO_EN_TEXT.entries()]
.filter(([source]) => source.length > 3)
.sort(([left], [right]) => right.length - left.length);
for (const [source, target] of replacements) {
translated = translated.replaceAll(source, target);
}
translated = translated
.replace(/Bereit in (\d+) ms/g, "Ready in $1 ms")
.replace(/Weitere (\d+) Geräte anzeigen/g, "Show $1 more devices")
.replace(/(\d+) von (\d+); Suche oder Typ weiter eingrenzen/g, "$1 of $2; narrow search or type")
.replace(/Szenario (\d+)/g, "Scenario $1")
.replace(/Ø (\d+) %/g, "avg. $1 %")
.replace(/(\d+) Beispiele/g, "$1 examples")
.replace(/(\d+) eindeutig/g, "$1 clear")
.replace(/(\d+) % Beitrag/g, "$1 % contribution")
.replace(/(\d+) % Profilklarheit/g, "$1 % profile clarity")
.replace(/mit (\d+) % Sicherheit/g, "with $1 % confidence")
.replace(/Prüfung ([^:]+):/g, "Check $1:")
.replace(/Keine Zusammenfassung/g, "No summary")
.replace(/Dauer: läuft\/offen/g, "Duration: running/open");
return translated;
}
function localizeFragment(root = document) {
for (const summary of root.querySelectorAll?.("details.collapsible > summary, .manual-context > summary, .group-panel > summary") || []) {
summary.dataset.closedLabel = ui("expand");
summary.dataset.openLabel = ui("collapse");
}
if (uiLang !== "en") return;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement;
if (!parent || ["SCRIPT", "STYLE", "TEXTAREA", "INPUT"].includes(parent.tagName)) {
return NodeFilter.FILTER_REJECT;
}
return node.nodeValue.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
},
});
const textNodes = [];
while (walker.nextNode()) textNodes.push(walker.currentNode);
for (const node of textNodes) {
node.nodeValue = translateText(node.nodeValue);
}
for (const input of root.querySelectorAll?.("input[placeholder], textarea[placeholder]") || []) {
input.placeholder = translateText(input.placeholder);
}
for (const optgroup of root.querySelectorAll?.("optgroup[label]") || []) {
optgroup.label = translateText(optgroup.label);
}
}
function applyStaticTranslations() {
document.body.dataset.closedLabel = ui("expand");
document.body.dataset.openLabel = ui("collapse");
for (const summary of document.querySelectorAll("details.collapsible > summary, .manual-context > summary, .group-panel > summary")) {
summary.dataset.closedLabel = ui("expand");
summary.dataset.openLabel = ui("collapse");
}
setText("header .brand p", "tagline");
setText("label[for='section-jump']", "menu");
const navOptions = document.querySelectorAll("#section-jump option");
@@ -693,6 +968,15 @@ 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);
}
function formatDateTime(value) {
@@ -713,6 +997,7 @@ function invalidateDashboardCache() {
cachedDiscovery = null;
cachedDashboardOverview = null;
cachedSystemOverview = null;
cachedRoomManagement = null;
cachedDetailHtml.clear();
}
@@ -879,8 +1164,9 @@ async function doLoadOverview() {
if (budget) {
const loadMs = dashboard._load_elapsed_ms;
budget.textContent = loadMs <= DASHBOARD_TIMEOUT_MS
? `Bereit in ${loadMs} ms`
: `Langsam: ${loadMs} ms`;
? `Bereit in ${loadMs} ms`
: `Langsam: ${loadMs} ms`;
budget.textContent = translateText(budget.textContent);
}
} catch (error) {
document.getElementById("configured-actuators").innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
@@ -918,10 +1204,11 @@ async function doLoadSystemOverview() {
budget.textContent = loadMs <= DASHBOARD_TIMEOUT_MS
? `Systemübersicht bereit in ${loadMs} ms`
: `Systemübersicht langsam: ${loadMs} ms`;
budget.textContent = translateText(budget.textContent);
}
scheduleDashboardExtras();
} catch (error) {
document.getElementById("status").innerHTML = `<p class="warn">Systemübersicht verzögert: ${escapeHtml(error.message)}</p>`;
document.getElementById("status").innerHTML = `<p class="warn">${escapeHtml(translateText(`Systemübersicht verzögert: ${error.message}`))}</p>`;
if (budget) budget.textContent = ui("system_delayed");
}
}
@@ -965,7 +1252,7 @@ async function loadDashboardExtras() {
if (reconciliation.status === "fulfilled") {
const text = document.getElementById("reconciliation-status");
if (text) {
text.textContent = `Letzte automatische Prüfung: ${formatDateTime(reconciliation.value.last_completed_at)}`;
text.textContent = translateText(`Letzte automatische Prüfung: ${formatDateTime(reconciliation.value.last_completed_at)}`);
}
}
} catch (_) {
@@ -1002,6 +1289,8 @@ async function loadStatus() {
status.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
chips.innerHTML = "";
}
localizeFragment(status);
localizeFragment(chips);
}
function renderDashboardStatus(dashboard) {
@@ -1066,6 +1355,9 @@ function renderDashboardStatus(dashboard) {
`<div class="metric"><strong>Cache-Zeitpunkt</strong>${escapeHtml(cache.updated_at || "noch offen")}</div>`,
].join("");
renderJobQueue(jobs);
localizeFragment(status);
localizeFragment(chips);
localizeFragment(stats);
}
function renderJobQueue(jobs) {
@@ -1086,6 +1378,169 @@ function renderJobQueue(jobs) {
</div>
`).join("")}
` : "";
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() {
@@ -1159,6 +1614,7 @@ function renderActuatorDiscovery() {
options.innerHTML = "";
select.innerHTML = `<option value="">Geräteliste konnte nicht geladen werden: ${escapeHtml(error.message)}</option>`;
}
localizeFragment(document.getElementById("choose"));
}
async function loadActuatorSuggestions() {
@@ -1185,6 +1641,7 @@ async function loadActuatorSuggestions() {
} catch (_) {
box.innerHTML = "";
}
localizeFragment(box);
}
function actuatorGroupLabel(domain) {
@@ -1232,6 +1689,7 @@ function renderActuatorSelect() {
</optgroup>
`),
].join("");
localizeFragment(select);
}
async function loadContextOptions(actuatorId) {
@@ -1277,6 +1735,7 @@ function renderManualContextSelect() {
select.innerHTML = filtered.length
? optionGroups(filtered, selectedNow)
: `<option value="">Keine passenden Vorschläge</option>`;
localizeFragment(select);
}
function parseEntityIds(value) {
@@ -1374,6 +1833,7 @@ function renderConfiguredActuators() {
} catch (error) {
box.textContent = error.message;
}
localizeFragment(box);
}
async function showActuator(actuatorId, evaluationMessage = "") {
@@ -1386,6 +1846,7 @@ async function showActuator(actuatorId, evaluationMessage = "") {
const box = document.getElementById("actuator-detail");
if (!evaluationMessage && cachedDetailHtml.has(actuatorId)) {
box.innerHTML = cachedDetailHtml.get(actuatorId);
localizeFragment(box);
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
renderConfiguredActuators();
return;
@@ -1762,12 +2223,14 @@ async function showActuator(actuatorId, evaluationMessage = "") {
${manualAssignment}
`;
box.innerHTML = detailHtml;
cachedDetailHtml.set(actuatorId, detailHtml);
localizeFragment(box);
cachedDetailHtml.set(actuatorId, box.innerHTML);
document.getElementById("detail").scrollIntoView({behavior: "smooth", block: "start"});
renderConfiguredActuators();
} catch (error) {
box.textContent = error.message;
}
localizeFragment(box);
}
function renderActuatorDetailShell(actuatorId) {
@@ -1784,6 +2247,7 @@ function renderActuatorDetailShell(actuatorId) {
<div class="metric"><strong>Kontext</strong>...</div>
</div>
`;
localizeFragment(document.getElementById("actuator-detail"));
}
async function hydrateContextOptions(record) {
@@ -1811,6 +2275,8 @@ async function hydrateContextOptions(record) {
${contextCategories.map(category => `<option value="${escapeHtml(category)}">${escapeHtml(category)}</option>`).join("")}
`;
renderManualContextSelect();
localizeFragment(numericSelect);
localizeFragment(categorySelect);
}
async function hydrateCurrentContextOptions(actuatorId) {
@@ -1912,6 +2378,7 @@ async function simulateActuator(actuatorId) {
} catch (error) {
box.innerHTML = `<p class="bad">${escapeHtml(error.message)}</p>`;
}
localizeFragment(box);
}
async function applySimulationWeights(actuatorId, scenarioId, startDryRun) {

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "sillyhome-next"
version = "1.7.4"
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