Add room management settings
This commit is contained in:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user