--- name: 'What to Wear (WTW) v1.0' type: architecture-spine purpose: build-substrate altitude: initiative paradigm: 'Pipes-and-Filters-Kern in einer Ports-and-Adapters-Schale (HA-Adapter außen, reiner Python-Kern innen)' scope: 'HACS-Custom-Integration what_to_wear v1.0 — gesamtes Produkt (Integration, Karte, Blueprints, CI, Distribution)' status: final created: '2026-07-11' updated: '2026-07-11' binds: [FR-1, FR-2, FR-3, FR-4, FR-5, FR-6, FR-7, FR-8, NFR-1, NFR-2, NFR-3, NFR-4, NFR-5, NFR-6, NFR-7, NFR-8, NFR-9, NFR-10] sources: - _bmad-output/planning-artifacts/prd.md (Rev. 3, final — führend) - _bmad-output/planning-artifacts/prd-addendum.md (Rev. 3 — §3–§5, §8 verbindliche v1-Defaults) - LEDGER.md (E-1…E-4) - docs/superpowers/specs/2026-07-11-what-to-wear-design.md companions: [] --- # Architecture Spine — What to Wear (WTW) v1.0 Rev. 2 nach 6-Linsen-Review (Rubrik, Web-Verifikation, Inkompatibilitäts-Angriff, Edge-Cases, Sicherheit/Recht, Input-Abgleich; Detail: `reviews/`). Alle HA-API-Aussagen sind gegen den Core-Quellcode **Tag 2025.3.0** bzw. offizielle Doku verifiziert (Belege: `.memlog.md`). Min-HA **2025.3** ist bestätigt haltbar (E-2 ✔). Dokumentierte Präzisierungen von PRD-/Addendum-Wortlaut sind je AD als **[PRÄZISIERUNG]** markiert und im Ledger geführt. ## Design Paradigm **Pipes-and-Filters-Kern in einer Ports-and-Adapters-Schale.** - **Kern (`logic/`):** reine, synchron testbare Python-Filter ohne jeden `homeassistant.*`-Import: `normalize.py → rules.py → matcher.py → texts.py → phraser.py (Prompt+Validierung)`. - **Ports (`weather/provider.py`):** `WeatherProvider`-Protocol (`async get_raw_forecast() → RawForecast`). - **Adapter (Integrationsschale):** `config_flow.py`, `coordinator.py`, `sensor.py`, `services.py`, `diagnostics.py`, `weather/ha_entity.py`, `frontend.py`, `llm/client.py` — kapseln alle `hass`-Zugriffe. ```mermaid graph LR subgraph Adapter [HA-Adapter - async, hass] CF[config_flow.py] --> CO[coordinator.py] HE[weather/ha_entity.py] --> CO CO --> SE[sensor.py] CO --> SV[services.py recommend + Event] LC[llm/client.py] --> CO FR2[frontend.py Karte] DG[diagnostics.py] end subgraph Kern [logic/ - rein, sync, kein hass] N[normalize.py] --> R[rules.py] --> M[matcher.py] --> T[texts.py] PV[phraser.py] MD[model.py + payload-Projektionen] end CO --> N CO --> PV SE --> MD SV --> MD ``` ## Invariants & Rules ### AD-1 — Abhängigkeitsrichtung: Kern kennt HA nicht - **Binds:** alle Module - **Prevents:** Domänenlogik, die nur im HA-Prozess läuft/testbar ist; schleichende `hass`-Kopplung - **Rule:** `logic/*` importiert ausschließlich Stdlib (+ eigene Module) — auch `dt_util` ist als `homeassistant.util.dt` verboten; Zeitzonen im Kern via `zoneinfo` (stdlib). Durchsetzung: ein Pflicht-Unit-Test scannt `logic/` auf `homeassistant`-Importe (Suite rot bei Bruch). Adapter rufen Kern-Funktionen mit reinen Datenobjekten auf; `weather/ha_entity.py` implementiert das Protocol aus `weather/provider.py`; der Coordinator kennt nur das Protocol. [ADOPTED: INV-6] ### AD-2 — Nie stumm, nie unmarkiert alt (Fehlermodell mit exaktem Prädikat) - **Binds:** coordinator, sensor, Karte, Blueprints, alle `logic/`-Stufen - **Prevents:** stumme Null-Empfehlungen; unentscheidbare Grenze Fehler vs. Degradation; unerkannt veraltete Stände; Sensor/Karte-Widerspruch beim Stale-Urteil - **Rule:** `Recommendation.status ∈ {ok, fehler_prognose}` — normatives Prädikat: `fehler_prognose` ⇔ Forecast-Abruf schlägt fehl **oder** kein einziger (daily- oder hourly-) Eintrag trägt das lokale Kalenderdatum des Zieldatums. Existiert mindestens ein Zieldatum-Eintrag (auch wenn alle Felder fehlend/unplausibel), gilt ausschließlich §3a-Degradation (status = ok + `data_notes`). Effektiv leerer Schrank ist **kein** Fehlerstatus (FR-2.3). Exceptions enden am Coordinator (`UpdateFailed` → letzte Empfehlung bleibt), nie im Sensor. **Stale-Besitz:** ausschließlich der Sensor berechnet `stale` (> 6 h) aus der vom Coordinator gepflegten Marke `last_success_utc` (Serverzeit) — bei jeder Listener-Benachrichtigung plus einem Einmal-Timer auf `last_success + 6 h` (exakter Kipp-Zeitpunkt). Die Karte liest nur das Attribut, rechnet nie mit der Browser-Uhr. LLM-Fehler ⇒ Regeltext, niemals Ausfall. [ADOPTED: INV-1] ### AD-3 — Zieldatum & Umschaltzeitpunkt strikt lokal, Grenze halboffen - **Binds:** coordinator, logic/, sensor, Karte - **Prevents:** UTC-/DST-Fehler; „morgen = now+24h"; falsches Zieldatum genau am Trigger-Zeitpunkt - **Rule:** Zieldatum = lokales Kalenderdatum in `hass.config.time_zone`; **morgen ⇔ lokale Uhrzeit ≥ Umschaltzeitpunkt** (halboffen; Pflicht-Fixture `now == switchover`). Recompute exakt am Umschaltzeitpunkt via `async_track_time_change` (feuert lokal, DST-fest — verifiziert). Liegt die konfigurierte Umschaltzeit in einer DST-Sprunglücke, begrenzt der Stunden-Tick das Fehlerfenster auf ≤ 1 h (dokumentiertes Accept + Fixture). `EVENT_CORE_CONFIG_UPDATE`-Listener → Refresh + Neu-Registrierung des Zeit-Listeners bei TZ-/Sprachwechsel. Zeitstempel-Parsing gehört dem Adapter (AD-24). [ADOPTED: INV-2] ### AD-4 — SI-Normmodell mit expliziter Herkunft (fehlend ≠ 0) - **Binds:** weather/ha_entity, logic/normalize, logic/rules - **Prevents:** falsche Skalen (°F/mph/m/s); 0-Interpretation fehlender Felder; geratene Einheiten - **Rule:** `weather.get_forecasts` liefert **Anzeige-Einheiten** (pro Entität umstellbar — verifiziert `_convert_forecast` 2025.3.0). Der Provider liest `temperature_unit`, `wind_speed_unit`, `precipitation_unit` aus den State-Attributen der Quell-Entität und reicht sie im `RawForecast` mit; `normalize.py` konvertiert explizit nach SI (°C, km/h, mm). **Fehlt das Unit-Attribut zu einem vorhandenen Wert ⇒ Feld fehlend + Vermerk** (Einheiten werden nie geraten). **Plausibilitätsgrenzen (§5) werden nach der SI-Konvertierung geprüft.** Jedes Normfeld ist ein `NormField(value: float|None, source: daily|hourly|derived|None, note)` — `value=None` heißt fehlend, nie 0. [ADOPTED: INV-3] ### AD-5 — Geheimnisse & Diagnostics-Whitelist - **Binds:** config_flow (Options), coordinator, llm/client, diagnostics, Logging - **Prevents:** Key-Leck über Logs/State/Attribute/Events/Diagnostics/Options-UI; Standort-Leck - **Rule:** LLM-Key nur in `entry.options` (HA-Config-Storage). **Options-Flow:** Key-Feld nutzt den Passwort-Selector, wird **nie** als default/suggested_value zurückgespielt; leeres Feld ⇒ Key bleibt unverändert; Entfernen nur über „LLM deaktivieren". **Diagnostics ist eine Whitelist**, kein redigierter Dump: Options (redigiert via Konstante `TO_REDACT` aus `const.py` = exakt `llm_api_key`, `weather_entity_id`), Norm-Kennwerte, Status, Zieldatum, `data_notes`, Stück-**Anzahl** je Kategorie (nie Namen); kein Feld, das die Wetter-Entity-ID trägt (auch nicht `source`). Pflicht-Test: Substring-Suche über den Diagnostics-JSON gegen Key-Wert und Entity-ID. Der LLM-Aktivierungs-Step zeigt vor dem Speichern die Datenübertragungs-Disclosure (Stücknamen, Wetterkennwerte, Zielsprache; Drittland; Provider-Bedingungen) als Step-Description; README trägt den Datenschutz-Abschnitt de+en inkl. at-rest/Backup-Hinweis (FR-6.4/6.5). Kein Log-Statement gibt Options-Inhalte oder LLM-Payloads aus. [ADOPTED: INV-4] ### AD-6 — Keine stille Netzlast, gehärteter LLM-Transport - **Binds:** manifest, llm/client - **Prevents:** Cloud-Abhängigkeiten; SDK-Versionskonflikte; Key-Leck über Redirects; Kosten-Stürme - **Rule:** `manifest.json` → `requirements: []`. Einzige externe Verbindung: der aktivierte LLM-Provider via `async_get_clientsession(hass)`. **Basis-URLs sind Konstanten** in `llm/client.py` (`https://api.openai.com/v1/chat/completions`, `https://api.anthropic.com/v1/messages`; Anthropic-Header `anthropic-version: 2023-06-01`, `max_tokens` Pflicht), **`allow_redirects=False`**, Timeout-Konstante **18 s** (Budget-Arithmetik: 10 s Wetter + 18 s LLM + Overhead < 30 s, NFR-7), Response-Body wird **größenbegrenzt gelesen** (max 64 KB, danach Abbruch ⇒ Fallback), **genau ein Versuch** pro Berechnung — jeder Nicht-2xx/Timeout (auch 429) ⇒ sofortiger Regeltext-Fallback, kein Retry (nächster Zyklus versucht erneut). Modell-ID ist Option mit Default (Stand 2026-07: `gpt-5.4-mini` / `claude-haiku-4-5` — bei Implementierung gegen die Live-Modelllisten prüfen). [ADOPTED: INV-5] ### AD-7 — Ein Mutator: der Coordinator; Start ohne Blockade; Reload nur bei Options-Änderung - **Binds:** __init__, coordinator, sensor, services, config_flow, Subentry-Flows - **Prevents:** konkurrierende Berechnungspfade; stummer Start (`ConfigEntryNotReady`-Endlosschleife ohne Sensor); Reload-Sturm pro Stück-Edit; Alt-Refresh schreibt nach Unload - **Rule:** Nur der `DataUpdateCoordinator` (erzeugt mit `config_entry=entry`, auto-shutdown beim Unload) berechnet die `Recommendation`. **Setup wartet nicht auf das Wetter:** kein `async_config_entry_first_refresh`; stattdessen `await coordinator.async_refresh()` ohne Raise-Semantik — der Sensor wird **immer** angelegt. **Fehler-Contract von `_async_update_data`:** Forecast-Fehler ohne Vorgänger-Daten (`coordinator.data is None`) ⇒ **return** `Recommendation(status=fehler_prognose)` (nie None-Daten für Sensor/Karte); Forecast-Fehler mit Vorgänger-Daten ⇒ raise `UpdateFailed` (letzte Empfehlung bleibt, Stale-Logik AD-2). Zusätzlich Listener auf die Quell-Entität (`async_track_state_change_event`) → Refresh beim ersten Verfügbarwerden. Trigger: `update_interval=1h`, Umschaltzeitpunkt (AD-3), Service `recommend` (AD-11), Quell-Entität wird verfügbar. Der Forecast-Abruf im Coordinator läuft in `asyncio.timeout(10)` ⇒ danach `UpdateFailed`. **update_listener:** Optionen verändert ⇒ `async_reload` (Zeit-Listener/ Session neu); nur Subentries verändert (Options-Snapshot unverändert) ⇒ nur `async_request_refresh` (Subentry-add/update/remove feuern die Update-Listener — verifiziert config_entries.py 2025.3.0 `_async_save_and_notify`). Sensor/Event/Response lesen nur `coordinator.data`; die Karte liest ausschließlich `sensor.what_to_wear` (State + Attribute). ### AD-8 — Speicher-Aufteilung & Schema-Versionierung - **Binds:** config_flow, Subentry-Flows, __init__ (Migration), diagnostics - **Prevents:** zwei Wahrheiten für dieselbe Einstellung; Datenverlust bei Schemaänderung - **Rule:** `entry.data` = genau ein Key `weather_entity_id`. Gewechselt wird die Wetter-Entität **im Options-Flow** (FR-1.4) mit Test-Abruf (AD-17); der Options-Handler schreibt sie via `async_update_entry` nach `entry.data`. `entry.options` = normatives Schema AD-23 (flach). Stücke = Subentries (`subentry_type="item"`, Datenschema AD-22). `VERSION=1, MINOR_VERSION=1`. Subentries haben keine eigene Version (verifiziert bis dev 2026-07) → Migration in `async_migrate_entry` über die Entry-Version, Subentry-Daten via `async_update_subentry` (NFR-10). **Robustheits-Contract beim Lesen:** fehlende Options-/Subentry-Keys ⇒ Default aus `const.py`; unbekannte Keys bleiben erhalten; typfalsche Werte ⇒ Default + Warn-Log — nie Setup-Abbruch wegen Datenform. ### AD-9 — Subentry-Flow: Reconfigure ist Pflicht, Zugriff nur über Kompat-Accessor - **Binds:** config_flow (ItemSubentryFlow) - **Prevents:** fehlender Bearbeiten-Button; AttributeError auf 2025.3 oder 2025.4+; Duplikat-Beispiel-Set - **Rule:** `ItemSubentryFlow` implementiert `async_step_user` **und** `async_step_reconfigure` (inkl. editierbarem Titel; Abschluss `async_update_and_abort`). API-Bruch 2025.3→2025.4: Zugriff nur über eigenen Kompat-Accessor (hasattr-Kaskade `_get_entry` → `_get_reconfigure_entry`; Parent-Entry im user-Step via `self.handler[0]` + `async_get_known_entry`). Beispiel-Set (FR-1.5): 12 Stücke als `ConfigSubentryData`-Liste in `ConfigFlow.async_create_entry(subentries=…)` — die Option existiert **ausschließlich im initialen user-Step**, nie im Reconfigure-/Options-Pfad. ### AD-10 — 2025.3-Kompatibilitäts-Leitplanken - **Binds:** alle Adapter, CI - **Prevents:** APIs, die es in 2025.3 nicht oder in aktuellen Versionen nicht mehr gibt - **Rule:** Verboten: `OptionsFlowWithReload` (erst 2025.8) → `entry.add_update_listener` + `async_reload`; `OptionsFlow.__init__(config_entry)`/Setter (bricht 2025.12) → Property-Muster; sync `register_static_path` (entfernt 2025.8) → nur `async_register_static_paths`; dict-Zugriff `hass.data["lovelace"]["…"]` (bricht 2026.2) → Attribut-Zugriff. CI erzwingt das: Matrix „min" (Py 3.13, phcc 0.13.225 = HA 2025.3.4) **und** „latest" (Py 3.14, phcc aktuell). ### AD-11 — Service `recommend`: garantiert frische Response + Event immer - **Binds:** services, Blueprints, coordinator - **Prevents:** alte Daten als Service-Response (Debounce); Event-Races; fehlschlagende Actions ohne `response_variable`; ungedeckelte Event-Payloads - **Rule:** Registrierung mit `SupportsResponse.OPTIONAL`. **Die Berechnung serialisiert der Coordinator selbst** (ein `asyncio.Lock` um den `_async_update_data`-Rumpf — `async_refresh` hat verifiziert kein internes Lock; parallele Zeit-/Service-Trigger laufen sonst gleichzeitig und können sich gegenseitig mit älteren Ergebnissen überschreiben). Der Handler ruft `await coordinator.async_refresh()` (**ungedrosselt** — `async_request_refresh` ist den Zeit-Triggern vorbehalten) und liest erst **nach Abschluss des eigenen Refresh** `coordinator.data`; die Response stammt garantiert aus einer Berechnung, die nach Eingang des Calls gestartet wurde (Pflicht-Integrationstest mit Provider-Zähler **inkl. parallel laufendem Zeit-Trigger**). Der Handler feuert **immer** `what_to_wear_recommendation` (Payload = `to_event()`-Projektion, AD-19 — gedeckelt, rechnerisch < 32 KB) und liefert das dict nur bei `call.return_response` (= `to_response()`, vollständig). Blueprints nutzen ausschließlich `response_variable`. ### AD-12 — Karten-Registrierung & Karten-Robustheit - **Binds:** frontend.py, www/what-to-wear-card.js, manifest, __init__ - **Prevents:** Duplikat-Ressourcen; Schreiben im YAML-Mode; Cache-Leichen; Setup-Race; **XSS**; JS-Absturz bei fehlender Entität - **Rule:** `manifest.dependencies = ["http", "frontend", "lovelace"]`. Statik: `async_register_static_paths([StaticPathConfig("/what_to_wear/what-to-wear-card.js", …)])`. Ressource nur bei `hass.data["lovelace"].mode == "storage"`: `loaded`-Check + `async_load()`, URL-Prefix-Scan über `async_items()` — vorhandene Ressource per `async_update_item` auf `…?v=` heben, sonst `async_create_item({"res_type": "module", …})`; Version stammt zur Laufzeit aus der Integration (manifest). YAML-Mode: nicht schreiben, README-Schritt. `async_remove_entry` löscht die Ressource; von Hand gelöschte Ressource heilt beim nächsten Setup (dokumentiert). **Karte:** rendert alle dynamischen Daten ausschließlich über `textContent`/DOM-APIs — nie `innerHTML` mit Daten; zeigt bei fehlender/`unavailable` Entität einen erklärenden Platzhalter (nie Exception); ruft **nie** Services auf. ### AD-13 — Zweigleisige i18n; die Karte rendert nur gelieferte Strings - **Binds:** translations/, logic/texts.py, coordinator, sensor, config_flow, Karte - **Prevents:** rote hassfest-CI durch eigene Translation-Keys; unlokalisierte/gemischte Texte; Browser-TZ-Fehler beim „heute/morgen"-Label - **Rule:** **Kein `strings.json`** — nur `translations/en.json` (vollständig) + `de.json`; registrierte Texte (Config-/Options-/Subentry-Flow inkl. `config_subentries.item.initiate_flow.{user,reconfigure}`, Entity-Name via `translation_key`, `services.*`, `exceptions.*`, Selector-Optionen) leben dort — hassfest-Schema erlaubt keine eigenen Top-Level-Keys. Dynamische Texte (Empfehlung, Gründe, Lücken, Datenlage, **Beispiel-Stück-Namen** — [PRÄZISIERUNG] ersetzt §8 „via Translations") leben in `logic/texts.py` (de/en). Sprachwahl je Berechnung: primäres Subtag von `hass.config.language` (`de*` → de, sonst en). Der Payload (AD-19) trägt `language` und fertig lokalisierte Anzeige-Strings inkl. Zieldatums-Label; die Karte rendert ausschließlich gelieferte Strings — nie `hass.locale`, nie Browser-Datum für heute/morgen. Das Offset-UI-Label macht die Richtung explizit (FR-1.3, translations). ### AD-14 — Ausgabegrößen erzwingt die Integration; Kürzen nur in Projektionen - **Binds:** logic/model.py (Projektionen), sensor, services - **Prevents:** `InvalidStateError`; Recorder-Totalverlust der Attribute; reihenfolgeabhängige Payloads durch In-place-Mutation - **Rule:** `Recommendation` ist **frozen** (immutable); sämtliches Größen-Enforcement lebt in den Projektionen (AD-19): `to_state()` ≤ 255 (deterministisch gekürzt), `to_attributes()` ≤ 15 KB serialisiert mit deterministischer Shedding-Kaskade (1. Stückliste kürzen ab 30 + `items_truncated`-Zähler, 2. Kurzgründe kürzen, 3. Requirement-Freitexte auf Keys reduzieren; Status/Zieldatum/Keys/Lücken weichen nie — NFR-8), `to_event()` ≤ 32 KB, `to_response()` vollständig. Kein Schreibzugriff auf `coordinator.data` außerhalb des Coordinators. LLM-Text nie in Attributen (AD-20). ### AD-15 — Entity-Identität - **Binds:** sensor - **Prevents:** sprach-/geräteabhängige Entity-IDs (Blueprints/Doku brechen) - **Rule:** `_attr_unique_id = "what_to_wear_recommendation"`, `self.entity_id = "sensor.what_to_wear"` **vor** `async_add_entities` (suggested_object_id-Pfad, verifiziert), `_attr_has_entity_name = True` + `translation_key`. manifest: `version` (Pflicht), `iot_class=calculated`, `integration_type=service`, `config_flow=true`, `single_config_entry=true`, `codeowners`, `documentation`, `issue_tracker`, `requirements=[]`. ### AD-16 — LLM ist Formulierer, nie Entscheider (abnahmefähige Validierung) - **Binds:** logic/phraser, llm/client, coordinator - **Prevents:** Prompt-Injection über Stücknamen; Delimiter-Kollision; leerer State; LLM-verfälschte Empfehlungen - **Rule:** Input ans LLM: ausschließlich strukturierte Empfehlung + Zielsprache; Stücknamen gelten als Untrusted Data und werden **JSON-String-escaped in einem JSON-Block** übergeben (macht Delimiter-Kollision unmöglich) + Ignorier-Instruktion (FR-6.2/6.6). Ausgabe-Validierung im Kern (`logic/phraser.py`, ohne Netz): (a) reiner Fließtext — kein `<`, `[`, ` ``` `, `http(s)://`; (b) ≤ 700 Zeichen; (c) jeder empfohlene Stückname wörtlich enthalten; (d) jede Lücke (Anzeigename) genannt; (e) nach Trim ≥ 20 Zeichen. Jede Verletzung ⇒ Regeltext. Payload-Feld `tone ∈ {llm, rules}` + kategorialer Grund (`llm_auth|llm_timeout|llm_invalid`) in `data_notes` macht den Dauer-Fallback sichtbar (keine Payloads, AD-5-konform). **Dokumentiertes Rest-Risiko:** Die Kriterien (a)–(e) sind die objektiven FR-6.6-Abnahmekriterien; semantische Manipulation (LLM folgt Anweisungen aus einem Stücknamen inhaltlich) ist damit nicht ausschließbar — akzeptiert, weil Input nur eigene Stücknamen (≤ 60 Zeichen) sind, die Ausgabe reiner Kurztext an den Besitzer ist und eine semantische Zweitvalidierung (LLM-Judge) neue Angriffsfläche + Kosten schüfe; README nennt das Risiko im Datenschutz-/LLM-Abschnitt. ### AD-17 — Forecast-Konsum: Service-Call mit Fehler-Taxonomie und einer einzigen Fenster-Definition - **Binds:** weather/ha_entity, config_flow (Test-Abruf), logic/normalize - **Prevents:** Absturz bei exotischen Entitäten; Setup-sagt-ok/Laufzeit-degradiert-Widerspruch; hourly-only-Blindflug - **Rule:** Einziger Weg: `hass.services.async_call("weather","get_forecasts", …, blocking=True, return_response=True)`; vorher `get_supported_features`-Check (daily und/oder hourly); nur-`twice_daily` ⇒ Fehlerfall 2. Fehler-Taxonomie Test-Abruf (FR-1.2): (1) keine `weather.*`-Entität, (2) `HomeAssistantError`/Feature-Check negativ, (3) Prognose reicht nicht bis Zieldatum, (4) temporär: Timeout 10 s bzw. `unavailable|unknown` bzw. Response-Key fehlt. Condition-Mapping: `{rainy,pouring,lightning-rainy,hail}→regen`, `{snowy,snowy-rainy}→schnee`, `{lightning,lightning-rainy}→gewitter`, unbekannt/None → fehlend. **Morgenfenster-Prädikat ([PRÄZISIERUNG] ersetzt §5 „vollständig abdeckt"):** abgedeckt ⇔ ≥ 1 hourly-Eintrag mit lokalem Zeitstempel in [06:00, 09:00); Fensterwert = Minimum der Fenster-Einträge; Test-Abruf und normalize rufen **dieselbe** Kern-Funktion `morning_window_coverage()`. **Fehlt daily** (nicht unterstützt/leer), werden Tagesaggregate deterministisch aus den hourly-Einträgen des Zieldatums abgeleitet (temp min/max, Menge = Summe, Wkt/Böen/UV = Maximum; `source=derived`) — [PRÄZISIERUNG] zu §5. `wind_bearing` kann str sein — nie ungeprüft numerisch parsen. ### AD-18 — Test-Architektur: met.no-Realität + Grenzwerte + Injection + E2E - **Binds:** tests/, CI, Release-Gate - **Prevents:** Abnahme nur gegen Ideal-Forecasts; Grenzwert-Kipper; ungetestete Injection-Pfade - **Rule:** `logic/`-Tests laufen ohne HA-Harness (AD-1). Pflicht-Fixtures: met.no-Profil (nie `apparent_temperature` ⇒ §3a-Fallback ist Normalfall; daily 6 Einträge inkl. templow), 3-h-Raster-hourly, hourly-only, °F/mph-Einheiten, DST-Tage (23/25 h), `now == switchover`, Umschaltzeit in DST-Sprunglücke, Band-Grenzwerte (7.9999 °C / 18.0 °C / Offset-Verschiebung end-to-end Flow→rules), Frost/Regen-Abnahme-Fixture (§8 ⇒ 7 Stücke + einzige Lücke Handschuhe), effektiv leerer Schrank, **Injection-Katalog** (bösartige Stücknamen: Markup, Links, Prompt-Befehle, 700-Zeichen-Namen ⇒ FR-6.6-Fallback greift), Diagnostics-Substring-Test (AD-5), Service-Frische-Test (AD-11), Start-mit-fehlender-Wetter-Entität-Test (Sensor existiert, `status=fehler_prognose`, nie None — AD-7), Ressourcen-Registrierungs-Test im latest-Job (fängt Lovelace-API-Drift — AD-12). **Release-Gate:** E2E in Wegwerf-HA (Docker): Einrichtung → Beispiel-Set → Karte → Blueprint-Push real; Negativfälle (keine Wetter-Entität, Entität ohne Forecast, falscher LLM-Key). ### AD-19 — Kanonischer Payload: eine Serialisierung, vier Projektionen - **Binds:** logic/model.py, sensor, services, Karte, Blueprints, Doku - **Prevents:** divergierende Attribut-/Event-/Response-Schemata; deutsche vs. englische Keys; stiller Funktionsverlust von Karte und Blueprint-Bedingungen - **Rule:** `logic/model.py` definiert **genau eine** Serialisierung `to_payload() → dict` mit normativer, **englischer** Key-Liste — deutsche Begriffe aus PRD/Spine (`zieldatum`, `veraltet`, `datenlage`, `prognose_stand`) sind Anzeigetexte, nie Keys: `status, target_date, target_label, created_at, forecast_fetched_at, language, tone, short_text, full_text, items[{name, category, warmth, reason}], items_truncated, requirements[{key, priority, reason}], gaps[{key, priority, cause}], metrics{feels_like_morning, temp_min, temp_max, rain_probability, rain_amount, wind_gust, uv_index, condition}, data_notes[], source, signature, changed, alert, gaps_count`. Sensor-Attribute = `to_attributes()`, Event = `to_event()`, Service-Response = `to_response()`, Karte liest `to_attributes()` + State — alle vier sind Projektionen desselben Payloads (Kürzungsregeln: AD-14; `llm_text`-Führung: AD-20). **Delta-Felder:** `signature` = stabiler Hash über (target_date, item-Namen sortiert, gap-Keys sortiert, alert); `alert` ⇔ mindestens ein muss-Requirement jenseits `base_*`; `changed` ⇔ signature ≠ persistierte Signatur der Vortags-Auslieferung (`helpers.storage.Store`, pro Entry). Diese drei Felder sind der verbindliche Anker der FR-7.4-Option „nur senden, wenn geändert oder Lücke/Warnlage". **Der Payload ist strukturell begrenzt:** `items` = gewählte Outfit-Stücke (≤ ~15, nie das Inventar), requirements/gaps ≤ Enum-Größe (AD-21), `data_notes` ≤ Degradations-Fälle, Texte ≤ 700 Zeichen — `to_event()` verifiziert ≤ 31 KB per Assert und wendet sonst die AD-14-Kaskade an; `to_response()` bleibt vollständig (FR-7.2) und ist durch dieselbe Struktur begrenzt. ### AD-20 — Textmodell: deterministischer State, Volltext nur in Event/Response - **Binds:** logic/texts, sensor, services, Karte, Blueprints - **Prevents:** LLM-Text ohne erreichbaren Kanal zur Karte (255-Limit); Karten-Service-Aufrufe als Lese-Ersatz; abgeschnittene LLM-Texte - **Rule:** Drei Textfelder: `short_text` (≤ 255, **immer deterministisch** — einziger State-Inhalt, auch bei aktivem LLM), `full_text` (Regeltext lang), `llm_text` (optional, ≤ 700; im Payload nur über `to_event()`/`to_response()` — nie in `to_attributes()`). Push/TTS-Blueprints (der Haupt-Ausspielweg des schönen Texts) verwenden `response.llm_text ?? response.full_text`. Die Karte zeigt `short_text` + Strukturdaten (items/gaps/target_label) — **kein LLM-Langtext auf der Karte in v1** (Deferred; braucht dedizierten read-only Abrufweg). [PRÄZISIERUNG zu FR-7.1 „State/Event/Karte": State kann 700 Zeichen technisch nicht tragen — Ledger-Eintrag.] ### AD-21 — Requirement-Registry & Merge-Semantik - **Binds:** logic/model.py (Enum), rules, matcher, texts, Karte, Doku - **Prevents:** drei legale Key-Lesarten (§3-deutsch/ASCII/englisch); Duplikat-Requirements; Prioritäts-Degradation durch Überschreiben - **Rule:** Ein normatives Enum in `logic/model.py` ist die **einzige** Quelle aller Requirement-Keys: `base_top, base_bottom, base_shoes, warmth:N, waterproof_outer, windproof_outer, sturdy_shoes, hat, gloves, scarf, sun_protection, hint_heat, hint_thunderstorm, hint_layering`. §3-Namen (`wasserdichte_außenschicht`, `mütze`, …) sind Anzeigetexte aus `logic/texts.py`; Mapping §3→Key ist 1:1 dieser Liste. Kein String-Literal außerhalb des Enums bildet einen Key (Test). **rules.py liefert eine kanonisch gemergte Map** `key → (priority, params)`: gleicher Key ⇒ `max(priority)` (muss > soll > kann); `warmth` ⇒ genau ein Eintrag mit `N = max(band_N, 4 bei Schnee)` (§3 „wärmebedarf ≥ 4" = Untergrenze); `hint_*` dedupliziert, erzeugen nie Lücken. Der Matcher setzt Eindeutigkeit voraus (Assert). Pflicht-Fixture: Frost+Schnee+Starkregen ⇒ genau ein `waterproof_outer (muss)`, ein `warmth:5`. ### AD-22 — Item-Schema-Registry (Subentry-Datenvertrag) - **Binds:** config_flow (ItemSubentryFlow, Beispiel-Set), coordinator, logic/matcher, translations - **Prevents:** deutsche Speicherform vs. englische Enums (KeyError/leerer Schrank trotz §8-Set); offene temp_range-Semantik; überlange Namen als Injection-/Layout-Vektor - **Rule:** Subentry-`data`-Keys normativ englisch: `name` (str, getrimmt, 1–60 druckbare Zeichen — Flow validiert), `category ∈ {top, sweater, jacket, bottom, shoes, head, hands, neck, accessory}` (Selector speichert Enum-Werte; Anzeige via Selector-Translations), `warmth` 1–5, `waterproof/windproof/sun_protection` bool (Default false), `formality ∈ {casual, business}` (Default casual), `temp_min/temp_max` °C optional (min ≤ max, Flow validiert), `active` bool (Default true). §8-Beispiel-Set ist Anzeige-Definition: Namen de/en aus `logic/texts.py` (Setup-Sprache), Kategorien = Enum-Werte ([PRÄZISIERUNG] zu §8/FR-2.1-UI-Begriffen). **Matcher-Randsemantik:** Temperaturbereichs-Überlappung mit `[morgen_wert, tagesmax]` ist beidseitig **inklusiv**; fehlt genau eine Intervallgrenze ⇒ Punkt-/Halbintervall aus der vorhandenen; Filter inaktiv nur, wenn beide fehlen. Bandzuordnung `[untere, obere)` **nach** Offset-Verschiebung auf **ungerundeten** SI-Werten; Rundung existiert nur in Anzeige-Projektionen. ### AD-23 — Normatives Options-Schema (flach) + Offset-Anwendungsort - **Binds:** config_flow (Options), const.py, rules, llm/client, diagnostics, __init__ - **Prevents:** Liste-vs-Einzelkeys-Drift; nested-vs-flach; doppelte oder fehlende Offset-Anwendung; Redaction-Fehlgriff; `HH:MM`-vs-`HH:MM:SS`-Crash - **Rule:** `const.py` trägt das vollständige Schema (Key, Typ, Bereich, Default) — flach, englisch: `warmth_band_limits: list[float]` (Länge 4, strikt monoton; Basis-Werte **ohne** Offset), `heat_threshold: float (28)`, `rain_prob_should: int (40)`, `rain_prob_must: int (70)`, `rain_amount_should: float (1.0)`, `rain_amount_must: float (5.0)`, `gust_should: float (40)`, `wind_proxy_should: float (30)`, `uv_should: int (6)`, `cold_sensitivity_offset: int −2…+2 (0)`, `switchover_time: str "HH:MM:SS"` (TimeSelector; geparst an genau einer Stelle mit `time.fromisoformat`), `llm_enabled: bool (false)`, `llm_provider ∈ {openai, anthropic}`, `llm_api_key: str`, `llm_model: str`. **Offset wird genau einmal angewendet, in `rules.py`:** `effective = base + 2 · cold_sensitivity_offset` für alle °C-Schwellen inkl. `heat_threshold` (+1 ⇒ +2 °C ⇒ es wird früher wärmer empfohlen — Richtung normativ; Regen/Wind/UV nie). Der Options-Flow zeigt Basiswerte. `TO_REDACT = {llm_api_key, weather_entity_id}` liegt neben dem Schema (AD-5). Pflicht-Fixture: Offset +1 verschiebt einen 7.5-°C-Morgen von Band 4 nach Band 3 (Flow→rules end-to-end). ### AD-24 — Zeit- und Parsing-Besitz: der Adapter parst, der Kern rechnet - **Binds:** weather/ha_entity, coordinator, logic/normalize - **Prevents:** dt_util-im-Kern-Verbotskonflikt (AD-1); doppeltes/fehlendes Parsing; divergierende „fehlend"-Muster je Schicht - **Rule:** `weather/ha_entity.py` parst **alle** Zeitstempel mit `dt_util.parse_datetime` und verwirft Unparsebares — **auch naive Ergebnisse ohne Offset** (kein Raten der Zeitzone, INV-3; Eintrag ⇒ fehlend + Vermerk). `RawForecast` trägt ausschließlich TZ-aware `datetime`-Objekte, den IANA-Namen `time_zone: str` und die Einheiten-Attribute (AD-4). `logic/` erhält nie Zeit-Strings; Kalenderdatums-Zuordnung im Kern via `zoneinfo` mit dem übergebenen TZ-Namen. Der `dt_util`-Satz in AD-3 gilt nur in Adaptern. ## Consistency Conventions | Concern | Convention | | --- | --- | | Code-Sprache | Code, Bezeichner, Kommentare, Logs: Englisch. Nutzertexte: de+en (AD-13) | | Öffentliche API-Keys | Attribut-/Event-/Response-Keys englisch = AD-19-Liste; deutsche Begriffe nur als Anzeigetexte | | Naming | Domain `what_to_wear`; Event `what_to_wear_recommendation`; Service `what_to_wear.recommend`; Options-Keys = AD-23 | | Requirement-/Kategorie-Vokabular | ausschließlich AD-21-/AD-22-Enums | | Datums-/Zeitformate | intern TZ-aware `datetime`; Payload ISO-8601 mit Offset; `target_date` als `YYYY-MM-DD`; `switchover_time` `"HH:MM:SS"` | | IDs | Stück-ID = `subentry_id` (ULID, HA-vergeben); nie Parallel-IDs | | Fehler-Shape | Kern wirft nie; `Recommendation.status` + `data_notes[]`. Adapter übersetzen HA-Exceptions in die 4 Flow-Fehlerfälle bzw. `UpdateFailed` | | Async-Disziplin | kein blockierendes IO im Event-Loop (NFR-2/C-2); Datei-IO via Executor; jeder externe Call mit Timeout (10 s Wetter, 18 s LLM) | | Logging | `_LOGGER` je Modul; nie Options-Inhalte/Keys/LLM-Payloads; Warnstufe nur nutzeraktion-würdig | | Versionskopplung | `manifest.json.version` = einzige Quelle; Git-Tag `v` = GitHub-Release; Karte `?v=` zur Laufzeit aus der Integration | | CI-Sicherheit | Workflows: `permissions: contents: read` als Default; Release-Workflow separat/minimal; Actions möglichst SHA-gepinnt (hassfest/HACS-Action: dokumentierte @ref-Nutzung, Risiko benannt) | ## Stack | Name | Version | | --- | --- | | Python | ≥ 3.13 (HA 2025.3), CI zusätzlich 3.14 (HA aktuell) | | Home Assistant Core (min) | 2025.3 (`hacs.json homeassistant: "2025.3.0"`, CI-Pin 2025.3.4) | | pytest-homeassistant-custom-component | 0.13.225 (min-Job) + aktuell (latest-Job; Stand 2026-07: 0.13.346) | | Laufzeit-Dependencies | keine (`requirements: []`) | | Karte | 1 Vanilla-JS-Datei, Custom Element, kein Build, keine Fremdbibliotheken | | CI | GitHub Actions: hassfest, HACS-Action (integration), pytest-Matrix min/latest — läuft auf dem GitHub-Repo (E-3-Mirror) | | Lizenz | Apache-2.0 (E-1) + NOTICE; keine gebündelten Fremdlizenzen | ## Structural Seed ```text custom_components/what_to_wear/ __init__.py # setup/unload/remove, migration, update_listener, Zeit-/Entity-Listener manifest.json # version, single_config_entry, dependencies [http,frontend,lovelace], # codeowners, documentation, issue_tracker, requirements [] const.py # Options-Schema, Defaults, TO_REDACT, Timeouts, URLs (AD-23) config_flow.py # ConfigFlow + OptionsFlow + ItemSubentryFlow (user+reconfigure) coordinator.py # einziger Mutator (AD-7) sensor.py # sensor.what_to_wear (AD-14/15) services.py # recommend (AD-11); services.yaml nur Struktur diagnostics.py # Whitelist + TO_REDACT (AD-5) frontend.py # Statik + Lovelace-Ressource (AD-12) weather/provider.py # WeatherProvider-Protocol (Port) weather/ha_entity.py # v1-Implementierung, parst Zeit/Einheiten (AD-17/AD-24) llm/client.py # OpenAI/Anthropic raw-HTTP, gehärtet (AD-6) logic/ # reiner Kern (AD-1): model.py (Datenformen, Enums, Payload+Projektionen), # normalize.py, rules.py, matcher.py, phraser.py, texts.py translations/ # en.json (vollständig) + de.json (AD-13) www/what-to-wear-card.js blueprints/automation/what_to_wear/ # notify_push.yaml, announce_tts.yaml (min_version 2025.3.0) tests/ # unit (logic, ohne HA) + integration (phcc) + fixtures/ (AD-18) .github/workflows/ # ci.yaml (Matrix, hassfest, HACS-Action), release.yaml (separat, minimal) hacs.json # {name, homeassistant: "2025.3.0"} README.md # Pflichtabschnitte: Datenschutz de+en (FR-6.5), at-rest/Backup-Hinweis # (FR-6.4), Inventar-Löschwarnung (FR-2.4), YAML-Mode-Schritt, # Blueprint-Import-Badges (FR-7.4), Setup-Doku-Links (J1) LICENSE, NOTICE # Apache-2.0 (NFR-9) ``` ```mermaid sequenceDiagram participant T as Trigger (1h / Start / Umschaltzeit / Service / Entity-verfügbar / Subentry) participant C as Coordinator participant W as weather/ha_entity participant L as logic (normalize→rules→matcher→texts) participant P as llm/client (optional) participant S as sensor.what_to_wear T->>C: refresh (Service: await async_refresh, AD-11) C->>W: get_raw_forecast() [timeout 10 s] W-->>C: RawForecast (TZ-aware, Einheiten) C->>L: build(zieldatum, raw, items, options, sprache) L-->>C: Recommendation (frozen; short/full_text) alt LLM aktiv C->>P: phrase(JSON-escaped) [timeout 25 s, 1 Versuch] P-->>C: llm_text → phraser validiert (a–e), sonst Regeltext end C-->>S: coordinator.data Note over S: to_state / to_attributes
Event=to_event, Response=to_response (AD-19) ``` ## Capability → Architecture Map | Capability | Lives in | Governed by | | --- | --- | --- | | FR-1 Einrichtung, Test-Abruf, Beispiel-Set | config_flow.py | AD-8, AD-9, AD-17, AD-22, AD-23 | | FR-2 Kleiderschrank (Subentries) | config_flow.py (ItemSubentryFlow) | AD-8, AD-9, AD-22 | | FR-2.4/6.4/6.5 Doku-/Disclosure-Pflichten | README.md, Options-Flow-Step (translations) | AD-5, AD-13, Seed | | FR-3 Prognose & Zieldatum | weather/, logic/normalize.py | AD-3, AD-4, AD-17 (§5 verbindlich, präzisiert), AD-24 | | FR-4 Regel-Engine | logic/rules.py | §3/§3a verbindlich, AD-21, AD-23 (Offset) | | FR-5 Matcher | logic/matcher.py | §4/§4a verbindlich, AD-21, AD-22 | | FR-6 LLM-Ton | logic/phraser.py + llm/client.py | AD-5, AD-6, AD-16, AD-20 | | FR-7.1 Sensor | sensor.py | AD-14, AD-15, AD-19 | | FR-7.2 Service/Event | services.py | AD-11, AD-19 | | FR-7.3 Karte | frontend.py + www/ | AD-12, AD-13, AD-19, AD-20 | | FR-7.4 Blueprints inkl. Delta-Option | blueprints/ | AD-11, AD-19 (signature/changed/alert), min_version | | FR-7.5 Aktualisierung/Stale | coordinator.py, sensor.py | AD-2, AD-3, AD-7 | | FR-8 i18n | translations/ + logic/texts.py | AD-13 | | NFR-2/C-2 Async | alle Adapter | Convention Async-Disziplin, AD-7 (Timeouts) | | NFR-4/5 Kompat & Distribution | CI, hacs.json, brands-PR | AD-10, Stack, Conventions | | NFR-7 Antwortzeiten | coordinator, llm/client | AD-7 (10 s), AD-6/AD-16 (25 s) | | NFR-8 Nachvollziehbarkeit | logic/model.py-Projektionen | AD-14 (Shedding schützt Keys), AD-19 | | NFR-9 Lizenz | LICENSE, NOTICE | Stack | | NFR-10 Migration | __init__.py | AD-8 | ## Deferred | Entscheidung | Warum sie warten kann | | --- | --- | | Open-Meteo-Provider (v1.1) | Port `WeatherProvider` existiert; zweite Implementierung berührt keinen AD | | LagerLens, Mehrpersonen, CRUD-Karte, Foto (E-4), Rotation, Export/Import | PRD-Non-Goals v1; AD-8-Migrationspfad hält offen | | LLM-Langtext auf der Karte | v1.1; braucht dedizierten read-only Abrufweg (AD-20); Karte zeigt v1 Kurztext + Struktur | | Repair-Issue bei wiederholtem LLM-Auth-Fehler | v1.1; v1 macht den Fallback über `tone`+`data_notes` sichtbar (AD-16) | | Feintuning Regel-Schwellwerte | §3 verbindlicher Default; Änderung = dokumentierte PRD-Abweichung | | Karten-Visuals (Layout/Farben) | reine Optik; Datenpfad ist durch AD-19/AD-20 fixiert | | Blueprint-Selector-Detail (Device- vs. Text-Selector fürs Push-Ziel) | Story-Ebene; Datenanker (response_variable, signature/changed/alert) sind fixiert | | GitHub-Mirror-Mechanik (E-3) | Benutzer-Entscheidung; Leitplanken vorgemerkt: Tag-Protection, minimaler Mirror-Token, Release nur aus Mirror-Stand mit Checksummen. Bis der Mirror steht, laufen AD-10-CI-Gates nicht — Story-Reihenfolge muss CI-Setup an den Mirror koppeln | | brands-PR-Zeitpunkt, HACS-Default-Antrag, docs/beta-Protokoll (S-2/S-3) | Release-Phase, operativ | | Ressourcen-Watchdog (von Hand gelöschte Karte zur Laufzeit heilen) | v1: Heilung beim Neustart dokumentiert (AD-12) | | quality_scale im Manifest | für Custom-Integrationen optional, kein Gate |