feat(2.5): Blueprints Push & TTS — Epic 2 komplett

Story 2.5 (deklaratives Artefakt, Struktur-Validierung, Suite gruen 130/130):
- blueprints/automation/what_to_wear/notify_push.yaml: device(mobile_app)+time+
  boolean, Bedingung changed/gaps_count/alert, notify.mobile_app_ via slugify.
- announce_tts.yaml: tts+media_player+trigger-Selector, tts.speak.
- Beide: what_to_wear.recommend via response_variable, rec.llm_text || full_text,
  min_version 2025.3.0, gehaertete Templates (.get()+Defaults), continue_on_error.
- luna-pro-Story-Review: 5/6 Findings uebernommen, 1 verworfen (flat-response-Evidenz).

Epic 2 (15-Minuten-Wow) komplett: Kleiderschrank->Karte->Service->Push/TTS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nora 2026-07-13 14:55:01 +00:00
parent dc941a127f
commit 8effa251cf
5 changed files with 186 additions and 5 deletions

View file

@ -5,7 +5,7 @@
> Dieses Ledger überlebt Kontext-Kompaktierung: erledigte Stories, offene Punkte, Gate-Ergebnisse
> (inkl. je Kritiker-Aufruf geschickter Dateien + Modell), Finding-Urteile.
**Stand:** 2026-07-13 · **Phase:** 5 LAUFEND — Story-Loop; **EPIC 1 KOMPLETT** (Stories 1.11.9, 108 Tests grün, committet): installierbar, `sensor.what_to_wear` zeigt echte Empfehlung. Umgebung `.venv` Py3.13 + HA 2025.3.4. · Nächster Epic: 2 (15-Minuten-Wow: Kleiderschrank→Karte→Push), Story 2.1
**Stand:** 2026-07-13 · **Phase:** 5 LAUFEND — Story-Loop; **EPIC 1+2 KOMPLETT** (Stories 1.12.5, 130 Tests grün): 15-Minuten-Wow-Pfad steht (Kleiderschrank→Karte→Service→Push/TTS). Umgebung `.venv` Py3.13 + HA 2025.3.4. · Nächster Epic: 3 (Anpassung & Robustheit), Story 3.1
## Benutzer-Entscheidungen E-1…E-4 (2026-07-11)
- **E-1 Lizenz: Apache-2.0** (bewusst statt MIT-Vorschlag).
@ -298,3 +298,10 @@ Pipes-and-Filters-Kern (`logic/`, hass-frei) in Ports-and-Adapters-Schale.
last_success_utc stempeln; F4 eigene Registrierung per Flag), 2 verworfen mit Evidenz (F2 frühe
Returns unerreichbar da Service nur mit Coordinator registriert + data nie None; F3 async_refresh
propagiert keine Exceptions, to_event/response projizieren jeden Status).
- **2.5 Blueprints Push & TTS** ✅ — 130 Tests grün. **EPIC 2 KOMPLETT.**
`blueprints/automation/what_to_wear/{notify_push,announce_tts}.yaml` + `tests/test_blueprints.py`
(deklaratives Artefakt: Struktur-/YAML-Validierung statt rotem Test). min_version 2025.3.0,
device/time/boolean bzw. tts/media_player/trigger-Selektoren, what_to_wear.recommend via
response_variable, changed/gaps_count/alert-Bedingung (FR-7.4/AD-11/20). **luna-pro-Review:**
6 Findings, 5 übernommen (F1F4 Template-Härtung mit .get()+Defaults; F5 continue_on_error am
notify), 1 verworfen mit Evidenz (F6 to_response ist flach, kein Nesting).

View file

@ -0,0 +1,42 @@
blueprint:
name: What to Wear — Announce (TTS)
description: >
Announces the clothing recommendation on a media player via text-to-speech
when your chosen trigger fires. It calls what_to_wear.recommend for a fresh
result and speaks the LLM text when available, otherwise the rule text.
domain: automation
min_version: "2025.3.0"
source_url: https://github.com/kenearos/what_to_wear/blob/main/blueprints/automation/what_to_wear/announce_tts.yaml
input:
tts_entity:
name: Text-to-speech engine
description: The tts.* entity used to synthesize speech.
selector:
entity:
domain: tts
media_player:
name: Media player
description: Where to announce the recommendation.
selector:
entity:
domain: media_player
announce_trigger:
name: Trigger
description: When to announce (e.g. a time, a button press, a person arriving).
selector:
trigger: {}
mode: single
trigger: !input announce_trigger
action:
- service: what_to_wear.recommend
response_variable: rec
- service: tts.speak
continue_on_error: true
target:
entity_id: !input tts_entity
data:
media_player_entity_id: !input media_player
message: "{{ rec.get('llm_text') or rec.get('full_text') or 'What to Wear' }}"

View file

@ -0,0 +1,55 @@
blueprint:
name: What to Wear — Push notification
description: >
Sends tomorrow's clothing recommendation to a mobile-app device at a chosen
time. It calls what_to_wear.recommend for a fresh result and can optionally
only notify when the recommendation changed or when there is a gap or alert.
domain: automation
min_version: "2025.3.0"
source_url: https://github.com/kenearos/what_to_wear/blob/main/blueprints/automation/what_to_wear/notify_push.yaml
input:
notify_device:
name: Mobile device
description: The mobile-app device that should receive the notification.
selector:
device:
integration: mobile_app
push_time:
name: Time
description: When to send the notification.
default: "20:00:00"
selector:
time: {}
only_if_relevant:
name: Only send when relevant
description: >
Only notify if the recommendation changed since the last delivery, or if
there is a gap or an alert (a must-requirement beyond the base outfit).
default: false
selector:
boolean: {}
mode: single
variables:
notify_device: !input notify_device
only_relevant: !input only_if_relevant
trigger:
- platform: time
at: !input push_time
action:
- service: what_to_wear.recommend
response_variable: rec
- condition: template
value_template: >
{{ (not only_relevant)
or (rec.get('changed') | default(false))
or (rec.get('gaps_count') | default(0) | int(0)) > 0
or (rec.get('alert') | default(false)) }}
- service: "notify.mobile_app_{{ device_attr(notify_device, 'name') | slugify }}"
continue_on_error: true
data:
title: What to Wear
message: "{{ rec.get('llm_text') or rec.get('full_text') or 'What to Wear' }}"

View file

@ -21,17 +21,16 @@ async def _unload_wtw_entries(request):
"""Unload any What to Wear entries after each hass test.
This cancels the coordinator's periodic refresh timer so it never lingers
into a following test under randomized ordering.
into a following test under randomized ordering. Only runs for tests that
actually use the ``hass`` fixture; guarded so pure-logic tests are untouched.
"""
yield
if "hass" not in request.fixturenames:
return
from homeassistant.config_entries import ConfigEntryState
from custom_components.what_to_wear.const import DOMAIN
hass = request.getfixturevalue("hass")
for entry in list(hass.config_entries.async_entries(DOMAIN)):
for entry in list(hass.config_entries.async_entries("what_to_wear")):
if entry.state is ConfigEntryState.LOADED:
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()

78
tests/test_blueprints.py Normal file
View file

@ -0,0 +1,78 @@
"""Story 2.5 — blueprint validity (declarative artifact, verifiable criterion).
Blueprints are not runtime-tested with a red test (DEV-METHOD: a declarative
artifact gets a pre-noted, verifiable acceptance criterion instead). Here we
assert they parse and carry the required structure, min_version, selectors, and
the response_variable-based call into what_to_wear.recommend.
"""
from __future__ import annotations
import pathlib
import yaml
BP_DIR = pathlib.Path(__file__).parent.parent / "blueprints" / "automation" / "what_to_wear"
class _BlueprintLoader(yaml.SafeLoader):
pass
def _passthrough(loader, node):
if isinstance(node, yaml.ScalarNode):
return {"__tag__": node.tag, "value": loader.construct_scalar(node)}
if isinstance(node, yaml.SequenceNode):
return loader.construct_sequence(node)
return loader.construct_mapping(node)
# Handle HA's custom !input tag so PyYAML can parse the blueprints.
_BlueprintLoader.add_constructor("!input", _passthrough)
def _load(name: str) -> dict:
with open(BP_DIR / name, encoding="utf-8") as fh:
return yaml.load(fh, Loader=_BlueprintLoader)
def _dump(obj) -> str:
return yaml.dump(obj, default_flow_style=False)
def test_push_blueprint_structure() -> None:
bp = _load("notify_push.yaml")
meta = bp["blueprint"]
assert meta["domain"] == "automation"
assert meta["min_version"] == "2025.3.0"
inputs = meta["input"]
assert inputs["notify_device"]["selector"]["device"]["integration"] == "mobile_app"
assert "time" in inputs["push_time"]["selector"]
assert "boolean" in inputs["only_if_relevant"]["selector"]
# calls the service with a response_variable and uses the response, not the event
text = _dump(bp)
assert "what_to_wear.recommend" in text
assert "response_variable" in text
assert "llm_text" in text and "full_text" in text
# the "only when relevant" logic uses changed/gaps_count/alert
assert "changed" in text and "gaps_count" in text and "alert" in text
def test_tts_blueprint_structure() -> None:
bp = _load("announce_tts.yaml")
meta = bp["blueprint"]
assert meta["domain"] == "automation"
assert meta["min_version"] == "2025.3.0"
inputs = meta["input"]
assert inputs["media_player"]["selector"]["entity"]["domain"] == "media_player"
assert "trigger" in inputs["announce_trigger"]["selector"]
text = _dump(bp)
assert "what_to_wear.recommend" in text
assert "response_variable" in text
assert "tts.speak" in text
assert "llm_text" in text and "full_text" in text
def test_both_have_source_url_for_import() -> None:
for name in ("notify_push.yaml", "announce_tts.yaml"):
bp = _load(name)
assert bp["blueprint"]["source_url"].startswith("https://")