what_to_wear/_bmad-output/planning-artifacts/architecture/architecture-what-to-wear-2026-07-11/ARCHITECTURE-SPINE.md
Nora 03dd8b573c arch: Architektur-Spine final (24 ADs) — Gate 2 bestanden, 6-Linsen-Review
Phase 3 der AI-Dev-Method abgeschlossen:
- 4 Recherche-Agenten verifizieren alle genutzten HA-APIs gegen Core-Tag 2025.3.0
  (E-2/NFR-4): min-HA 2025.3 haltbar (Subentry-Reconfigure ab 2025.3).
- Pipes-and-Filters-Kern (logic/, hass-frei) in Ports-and-Adapters-Schale.
- 6 adversariale Reviewer-Linsen -> Spine Rev. 2 (neue AD-19..24: kanonischer
  Payload, Textmodell, Requirement-/Item-/Options-Registry, Parsing-Besitz).
- Gate 2 (luna-pro, qwen offline): 9 Findings, 6 uebernommen / 3 verworfen (Evidenz).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:47:24 +00:00

38 KiB
Raw Permalink Blame History

name type purpose altitude paradigm scope status created updated binds sources companions
What to Wear (WTW) v1.0 architecture-spine build-substrate initiative Pipes-and-Filters-Kern in einer Ports-and-Adapters-Schale (HA-Adapter außen, reiner Python-Kern innen) HACS-Custom-Integration what_to_wear v1.0 — gesamtes Produkt (Integration, Karte, Blueprints, CI, Distribution) final 2026-07-11 2026-07-11
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
_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

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.
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.jsonrequirements: []. 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() (ungedrosseltasync_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=<version> 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, 160 druckbare Zeichen — Flow validiert), category ∈ {top, sweater, jacket, bottom, shoes, head, hands, neck, accessory} (Selector speichert Enum-Werte; Anzeige via Selector-Translations), warmth 15, 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<version> = GitHub-Release; Karte ?v=<version> 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

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)
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 (ae), sonst Regeltext
  end
  C-->>S: coordinator.data
  Note over S: to_state / to_attributes<br/>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