diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a46e8f..d6efbe0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 1.0.0 - 2026-06-17 +- Neuer blockweiser Dashboard-Start über `/v1/actuators/dashboard`: lokale + Store-/Cache-Daten laden sofort, HA-Discovery und Vorschläge laufen + nachgelagert. +- Discovery liest Entities pro Anfrage nur noch einmal und klassifiziert aus + diesem Snapshot weiter. Dadurch entfallen doppelte HA-Vollabfragen. +- Persistenter JSON-Entity-Cache wird für Friendly Name, Raum, Gerät, + Discovery-Gruppen und schnelle Summaries genutzt. +- Dashboard mit Orange als Primärfarbe, kompakter Navigation, aufklappbarer + Anleitung, aufklappbaren Gerätegruppen und Cache-/Systemstatistik. +- Aktor-/Sensor-Kategorien erweitert: Feuchte, Wetter, Helligkeit, Bewegung, + Tür/Fenster, Präsenz, Lichtzustände, Schalter, Steckdosen, Lüftung, Heizung, + Cover, Helper, PV/Akku/Einspeisung. +- Kontextvorschläge vermeiden weitere doppelte HA-Discovery und sortieren + aktortypbezogen nach relevanten Bereichen. + ## 0.7.21 - 2026-06-17 - Dashboard-Ladepfad getrennt: beobachtete Geräte laden sofort über `/v1/actuators/summary`; Status, Discovery und Vorschläge laufen unabhängig diff --git a/README.md b/README.md index 2075e4e..78938c1 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ nach einer ausdrücklichen Freigabe ausführen. [`docs/CONTROL_HANDOFF.md`](docs/CONTROL_HANDOFF.md) - Entwickeln, testen, veröffentlichen und installieren: [`docs/OPERATIONS.md`](docs/OPERATIONS.md) +- Version 1.0.0 bedienen und prüfen: + [`docs/V1_0_0_OPERATING_GUIDE.md`](docs/V1_0_0_OPERATING_GUIDE.md) - Arbeitsregeln für Coding-Agenten: [`AGENTS.md`](AGENTS.md) ## Reifegrad @@ -58,6 +60,8 @@ uvicorn app.main:app --reload - `http://127.0.0.1:8000/v1/discovery` - klassifizierte, filterbare Entities - `http://127.0.0.1:8000/v1/history` - normalisierte numerische Zeitreihen - `http://127.0.0.1:8000/v1/actuators/discovery` - unterstützte Aktuatoren für den aktor-zentrierten Workflow +- `http://127.0.0.1:8000/v1/actuators/dashboard` - schnelle Dashboard-Startdaten aus Store und JSON-Cache +- `http://127.0.0.1:8000/v1/actuators/summary` - schlanke Liste beobachteter Aktoren - `POST http://127.0.0.1:8000/v1/actuators` - Aktor freigeben; Kontextzuordnung und Modell-Lebenszyklus starten automatisch - `POST http://127.0.0.1:8000/v1/actuators/{entity_id}/evaluate` - Shadow-Vorhersage aktualisieren - `POST http://127.0.0.1:8000/v1/actuators/{entity_id}/activation` - autonomes Schalten pro Aktor freigeben oder stoppen diff --git a/addon/config.yaml b/addon/config.yaml index b3b9df8..0185547 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -1,5 +1,5 @@ name: SillyHome Next -version: "0.7.21" +version: "1.0.0" slug: sillyhome_next description: Lernt automatisch aus deinem Verhalten und steuert freigegebene Aktoren url: http://192.168.6.31:3000/pino/sillyhome-next diff --git a/app/actuators/lifecycle.py b/app/actuators/lifecycle.py index ed81b2f..4fc7f6f 100644 --- a/app/actuators/lifecycle.py +++ b/app/actuators/lifecycle.py @@ -20,7 +20,7 @@ from app.actuators.models import ( ) from app.actuators.store import ActuatorStore from app.config import Settings -from app.ha.discovery import DiscoveredEntity, EntityRole +from app.ha.discovery import DiscoveredEntity, EntityRole, discover_entities from app.ha.history import EntityHistorySeries, NumericHistoryPoint from app.ha.models import HaEntitySummary from app.ha.reader import HaReader @@ -170,7 +170,7 @@ class ActuatorReconciliationService: limit: int = _CONTEXT_SUGGESTION_LIMIT, ) -> list[HaEntitySummary]: entities = {entity.entity_id: entity for entity in self._ha_reader.read_entities()} - discovered = {entity.entity_id: entity for entity in self._ha_reader.discover()} + discovered = {entity.entity_id: entity for entity in discover_entities(list(entities.values()))} actuator = entities.get(actuator_entity_id) if actuator is None: raise KeyError("Aktuator-Konfiguration nicht gefunden.") @@ -736,6 +736,11 @@ def _manual_context_role( def _context_sort_group(entity: HaEntitySummary) -> str: device_class = entity.device_class or "" + text = " ".join( + value.lower().replace("_", " ") + for value in [entity.entity_id, entity.friendly_name, entity.area_name, entity.device_name] + if value + ) if device_class in {"motion", "occupancy", "presence"}: return "01_presence" if device_class in {"illuminance"}: @@ -744,10 +749,26 @@ def _context_sort_group(entity: HaEntitySummary) -> str: return "03_opening" if device_class in {"humidity", "moisture"}: return "04_humidity" + if device_class in {"temperature"}: + return "05_temperature" + if any(token in text for token in {"pv", "solar", "akku", "batterie", "battery", "einspeisung"}): + return "06_pv_battery" if device_class in {"power", "energy", "current", "voltage"}: - return "05_power" + return "07_power" + if entity.domain in {"weather"}: + return "08_weather" + if entity.domain in {"fan", "humidifier"}: + return "09_ventilation" + if entity.domain in {"climate"}: + return "10_heating" + if entity.domain in {"cover"}: + return "11_cover" if entity.domain in {"light", "switch"}: - return "06_states" + return "12_states" + if entity.domain.startswith("input_"): + return "13_helper" + if entity.domain in {"person", "device_tracker"}: + return "14_people" return f"20_{entity.domain}_{device_class}" @@ -900,11 +921,12 @@ def _merge_manual_candidates( def _preferred_device_classes(domain: str, *, context: bool) -> frozenset[str]: if context: mapping = { - "climate": {"occupancy", "presence", "window"}, - "cover": {"illuminance", "wind_speed"}, + "climate": {"humidity", "illuminance", "occupancy", "presence", "temperature", "window"}, + "cover": {"illuminance", "motion", "occupancy", "presence", "wind_speed"}, "fan": {"humidity", "moisture", "occupancy", "presence", "temperature"}, "humidifier": {"humidity", "moisture", "temperature"}, - "light": {"door", "garage_door", "motion", "occupancy", "opening", "presence", "window"}, + "light": {"door", "garage_door", "illuminance", "motion", "occupancy", "opening", "presence", "window"}, + "media_player": {"occupancy", "presence"}, "switch": {"door", "garage_door", "motion", "occupancy", "opening", "presence", "window"}, } return frozenset( diff --git a/app/api/v1/actuators.py b/app/api/v1/actuators.py index 5e8eb69..2231cec 100644 --- a/app/api/v1/actuators.py +++ b/app/api/v1/actuators.py @@ -14,7 +14,7 @@ from app.actuators.store import ActuatorStore from app.behavior.engine import BehaviorEngine from app.config import Settings from app.dependencies import get_ha_reader -from app.ha.discovery import DiscoveredEntity, EntityRole +from app.ha.discovery import DiscoveredEntity, EntityRole, discover_entities from app.ha.exceptions import HaClientError from app.ha.models import HaEntitySummary from app.ha.reader import HaReader @@ -79,6 +79,35 @@ class ActuatorSummary(BaseModel): updated_at: str +class EntityCacheStatus(BaseModel): + available: bool + updated_at: str | None = None + entity_count: int = 0 + + +class DashboardSystemStatus(BaseModel): + api_status: str = "ok" + websocket_status: str = "unavailable" + websocket_error: str | None = None + reconciliation_last_completed_at: str | None = None + configured_actuators: int = 0 + trained_models: int = 0 + review_required: int = 0 + + +class DashboardDiscoveryGroup(BaseModel): + category: str + role: str + count: int + + +class DashboardOverview(BaseModel): + system: DashboardSystemStatus + cache: EntityCacheStatus + actuators: list[ActuatorSummary] + discovery_groups: list[DashboardDiscoveryGroup] + + @router.get("/discovery", response_model=list[HaEntitySummary]) def discover_actuators( request: Request, @@ -92,7 +121,7 @@ def discover_actuators( fresh_entities = list(ha_reader.read_entities()) _save_cached_entities(request, fresh_entities) entities = {entity.entity_id: entity for entity in fresh_entities} - discovered = ha_reader.discover() + discovered = discover_entities(list(entities.values())) actuator_ids = _deduplicate_actuator_ids( [ (entity.entity_id, entity.category) @@ -110,7 +139,7 @@ def suggest_actuators( ha_reader: HaReader = Depends(get_ha_reader), ) -> list[ActuatorSuggestion]: entities = {entity.entity_id: entity for entity in ha_reader.read_entities()} - discovered = {entity.entity_id: entity for entity in ha_reader.discover()} + discovered = {entity.entity_id: entity for entity in discover_entities(list(entities.values()))} configured_ids = {record.actuator_entity_id for record in _service(request).list_configured()} actuator_ids = _deduplicate_actuator_ids( [ @@ -222,6 +251,53 @@ def list_configured_summary(request: Request) -> list[ActuatorSummary]: ] +@router.get("/dashboard", response_model=DashboardOverview) +def dashboard_overview(request: Request) -> DashboardOverview: + cache_payload = _load_entity_cache_payload(request) + raw_entities = cache_payload.get("entities", []) + if not isinstance(raw_entities, list): + raw_entities = [] + raw_updated_at = cache_payload.get("updated_at") + updated_at = raw_updated_at if isinstance(raw_updated_at, str) else None + cached_entities = [ + HaEntitySummary.model_validate(entity) + for entity in raw_entities + if isinstance(entity, dict) + ] + discovered = discover_entities(cached_entities) if cached_entities else [] + group_counts: dict[tuple[str, str], int] = {} + for entity in discovered: + key = (entity.category, entity.role.value) + group_counts[key] = group_counts.get(key, 0) + 1 + reconciliation = _reconciliation_state_or_default(request) + ws_status = getattr(request.app.state, "ws_status", None) + actuators = list_configured_summary(request) + return DashboardOverview( + system=DashboardSystemStatus( + websocket_status=getattr(ws_status, "status", "unavailable"), + websocket_error=getattr(ws_status, "error", None), + reconciliation_last_completed_at=( + reconciliation.last_completed_at.isoformat() + if reconciliation.last_completed_at is not None + else None + ), + configured_actuators=len(actuators), + trained_models=reconciliation.trained_models, + review_required=reconciliation.review_required, + ), + cache=EntityCacheStatus( + available=bool(cached_entities), + updated_at=updated_at, + entity_count=len(cached_entities), + ), + actuators=actuators, + discovery_groups=[ + DashboardDiscoveryGroup(category=category, role=role, count=count) + for (category, role), count in sorted(group_counts.items()) + ], + ) + + @router.get("", response_model=list[ActuatorRecord]) def list_configured(request: Request) -> list[ActuatorRecord]: return _service(request).list_configured() @@ -412,6 +488,16 @@ def _behavior(request: Request) -> BehaviorEngine: return engine +def _reconciliation_state_or_default(request: Request) -> ReconciliationState: + store = getattr(request.app.state, "actuator_store", None) + if not isinstance(store, ActuatorStore): + return ReconciliationState() + try: + return store.load_reconciliation_state() + except ValueError: + return ReconciliationState(last_summary="Reconciliation-Status ist unlesbar.") + + def _entity_cache_path(request: Request) -> Path: store = getattr(request.app.state, "actuator_store", None) settings = getattr(request.app.state, "settings", None) @@ -425,17 +511,27 @@ def _entity_cache_path(request: Request) -> Path: def _load_cached_entities(request: Request) -> list[HaEntitySummary]: - path = _entity_cache_path(request) - if not path.exists(): + payload = _load_entity_cache_payload(request) + raw_entities = payload.get("entities", []) + if not isinstance(raw_entities, list): return [] try: - payload = json.loads(path.read_text(encoding="utf-8")) - raw_entities = payload.get("entities", []) return [HaEntitySummary.model_validate(entity) for entity in raw_entities] - except (OSError, TypeError, ValueError): + except ValueError: return [] +def _load_entity_cache_payload(request: Request) -> dict[str, object]: + path = _entity_cache_path(request) + if not path.exists(): + return {} + try: + payload = json.loads(path.read_text(encoding="utf-8")) + return payload if isinstance(payload, dict) else {} + except (OSError, TypeError, ValueError): + return {} + + def _save_cached_entities(request: Request, entities: list[HaEntitySummary]) -> None: path = _entity_cache_path(request) path.parent.mkdir(parents=True, exist_ok=True) diff --git a/app/ha/discovery.py b/app/ha/discovery.py index f0a9414..e61576b 100644 --- a/app/ha/discovery.py +++ b/app/ha/discovery.py @@ -214,9 +214,12 @@ def _result( def _actuator_category(entity: HaEntitySummary) -> str: + text = _entity_text(entity) if entity.domain == "light": return "light" if entity.domain == "switch": + if any(token in text for token in {"steckdose", "socket", "plug", "outlet", "shelly"}): + return "socket" return "switch_socket" if entity.domain == "button" or entity.domain == "input_button": return "button" @@ -239,13 +242,31 @@ def _actuator_category(entity: HaEntitySummary) -> str: def _measurement_category(entity: HaEntitySummary) -> str: device_class = entity.device_class or "" + text = _entity_text(entity) + if any( + token in text + for token in { + "pv", + "solar", + "photovoltaik", + "akku", + "batterie", + "battery", + "einspeisung", + "wechselrichter", + "inverter", + } + ): + return "pv_battery_grid" + if entity.domain == "weather": + return "weather" if device_class == "illuminance": return "brightness" if device_class == "temperature": return "temperature" if device_class in {"humidity", "moisture"}: return "humidity" - if device_class in {"power", "energy", "current", "voltage"}: + if device_class in {"power", "energy", "current", "voltage", "apparent_power"}: return "energy_power" if device_class in {"battery", "signal_strength"}: return "diagnostic" @@ -260,12 +281,42 @@ def _binary_category(entity: HaEntitySummary) -> str: return "opening" if device_class in {"smoke", "safety", "problem"}: return "safety" + if device_class in {"lock"}: + return "lock_state" return "binary" def _context_category(entity: HaEntitySummary) -> str: + text = _entity_text(entity) if entity.domain.startswith("input_"): return "helper" if entity.domain in {"person", "device_tracker", "zone"}: return "presence_location" + if entity.domain == "weather": + return "weather" + if entity.domain in {"light"}: + return "light_state" + if entity.domain in {"switch"}: + if any(token in text for token in {"steckdose", "socket", "plug", "outlet", "shelly"}): + return "socket_state" + return "switch_state" + if entity.domain in {"climate"}: + return "heating_state" + if entity.domain in {"fan", "humidifier"}: + return "ventilation_state" + if entity.domain in {"cover"}: + return "cover_state" return entity.domain + + +def _entity_text(entity: HaEntitySummary) -> str: + return " ".join( + value.lower().replace("_", " ") + for value in [ + entity.entity_id, + entity.friendly_name, + entity.area_name, + entity.device_name, + ] + if value + ) diff --git a/app/main.py b/app/main.py index 58b70bd..31bacfe 100644 --- a/app/main.py +++ b/app/main.py @@ -105,7 +105,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app = FastAPI( title="SillyHome Next API", description="Lokales Smart-Home-Intelligenzsystem für Home Assistant.", - version="0.7.21", + version="1.0.0", lifespan=lifespan, ) app.state.settings = load_settings() diff --git a/app/static/index.html b/app/static/index.html index d5edd55..b5fe384 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -5,18 +5,18 @@ SillyHome Next