| 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.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=<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, 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<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 (a–e), 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 |