Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ff005f089 | |||
| 954c4511a7 |
19
CHANGELOG.md
19
CHANGELOG.md
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user