From f5d787ccdea33a7f4dc1ffc510131630b18400f6 Mon Sep 17 00:00:00 2001 From: ruv Date: Sat, 23 May 2026 15:01:20 -0400 Subject: [PATCH] feat(adr-115): ship 8 starter HA Blueprints + YAML validator + CI lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per ADR-115 §9.4 (maintainer ACK on #776), v0.7.0 ships **3 starter blueprints**. This commit goes further: all **8** of the catalog proposed in §3.12.2 land as standalone YAML files under `examples/ha-blueprints/`, ready to import into HA. ## Blueprints 1. Notify on possible distress → possible_distress 2. Dim hallway when sleeping → someone_sleeping 3. Wake routine on bed exit → bed_exit (time-window-gated) 4. Alert on elderly inactivity → elderly_inactivity_anomaly (with optional escalation chain) 5. Meeting lights + presence mode → meeting_in_progress (activates a HA scene) 6. Bathroom fan while occupied → bathroom_occupied (privacy-mode-safe; zone-derived) 7. Escalate on fall-risk crossing → fall_risk_elevated (numeric_state trigger) 8. Auto-arm security when not active → group(room_active) + no_movement (composed; multi-room sense) Each blueprint: - Uses HA's blueprint schema (https://www.home-assistant.io/docs/blueprint/schema/) - Declares typed `selector:` for every input (entity-domain-constrained where applicable) - Carries a `source_url` for HACS-style re-import - Includes `mode: single` + `max_exceeded: silent` where appropriate so transient retriggers don't spam - Includes a `cooldown_minutes` / `confirm_minutes` / `ack_timeout_min` parameter where time-debouncing matters ## Validator (`scripts/validate-ha-blueprints.py`) Pure-Python validator that: - Registers no-op constructors for HA's `!input` and `!secret` YAML tags (PyYAML doesn't know them) - Asserts every file has a top-level `blueprint:` mapping with `name`/`description`/`domain` - Asserts `domain` is `automation` or `script` - Asserts at least one declared `input` - Asserts at least one of `trigger`/`action`/`sequence` is present Exits 0 only when all 8 validate. Local run: python scripts/validate-ha-blueprints.py All 8 HA Blueprints validate OK ## CI integration `.github/workflows/mqtt-integration.yml` gains a new `Validate HA Blueprints` step that runs the Python validator before the cargo test phases — fails the workflow on any malformed blueprint in a PR. ## Privacy-mode coverage table 5 of 8 blueprints are unconditionally privacy-mode-safe (no biometric dependency in the state derivation). The other 3 depend on inferred states that themselves derive from biometrics — the inferred state still publishes under `--privacy-mode` (per ADR §3.12.3) but the operator should audit the use case in regulated contexts. Full table in `examples/ha-blueprints/README.md`. Refs #776, PR #778. Co-Authored-By: claude-flow --- .github/workflows/mqtt-integration.yml | 5 + .../01-notify-on-possible-distress.yaml | 51 ++++++++ .../02-dim-hallway-when-sleeping.yaml | 52 ++++++++ .../03-wake-routine-on-bed-exit.yaml | 74 ++++++++++++ .../04-alert-elderly-inactivity-anomaly.yaml | 70 +++++++++++ .../05-meeting-lights-presence-mode.yaml | 52 ++++++++ .../06-bathroom-fan-while-occupied.yaml | 52 ++++++++ .../07-fall-risk-escalation.yaml | 44 +++++++ .../08-auto-arm-security-when-not-active.yaml | 65 ++++++++++ examples/ha-blueprints/README.md | 60 +++++++++ scripts/validate-ha-blueprints.py | 114 ++++++++++++++++++ 11 files changed, 639 insertions(+) create mode 100644 examples/ha-blueprints/01-notify-on-possible-distress.yaml create mode 100644 examples/ha-blueprints/02-dim-hallway-when-sleeping.yaml create mode 100644 examples/ha-blueprints/03-wake-routine-on-bed-exit.yaml create mode 100644 examples/ha-blueprints/04-alert-elderly-inactivity-anomaly.yaml create mode 100644 examples/ha-blueprints/05-meeting-lights-presence-mode.yaml create mode 100644 examples/ha-blueprints/06-bathroom-fan-while-occupied.yaml create mode 100644 examples/ha-blueprints/07-fall-risk-escalation.yaml create mode 100644 examples/ha-blueprints/08-auto-arm-security-when-not-active.yaml create mode 100644 examples/ha-blueprints/README.md create mode 100644 scripts/validate-ha-blueprints.py diff --git a/.github/workflows/mqtt-integration.yml b/.github/workflows/mqtt-integration.yml index 8aed4c6b..56bbb43d 100644 --- a/.github/workflows/mqtt-integration.yml +++ b/.github/workflows/mqtt-integration.yml @@ -77,6 +77,11 @@ jobs: with: workspaces: v2 -> target + - name: Validate HA Blueprints + run: | + python -m pip install --quiet pyyaml + python scripts/validate-ha-blueprints.py + - name: Verify unit tests still pass under --features mqtt working-directory: v2 # `cargo test` accepts a single TESTNAME filter, so we run the diff --git a/examples/ha-blueprints/01-notify-on-possible-distress.yaml b/examples/ha-blueprints/01-notify-on-possible-distress.yaml new file mode 100644 index 00000000..18e400f4 --- /dev/null +++ b/examples/ha-blueprints/01-notify-on-possible-distress.yaml @@ -0,0 +1,51 @@ +blueprint: + name: RuView — notify on possible distress + description: > + Send a push notification when RuView's HA-MIND inference layer + detects sustained elevated heart rate + agitated motion without a + fall (possible_distress primitive). Includes the explainability + reason payload so the recipient knows why the alert fired. + Part of the ADR-115 §3.12 starter blueprint set. + domain: automation + source_url: https://github.com/ruvnet/RuView/blob/main/examples/ha-blueprints/01-notify-on-possible-distress.yaml + input: + distress_entity: + name: Possible distress binary_sensor + description: The `binary_sensor.*_possible_distress` entity published by RuView. + selector: + entity: + domain: binary_sensor + notify_target: + name: Notification service + description: Notify service to call (e.g. `notify.mobile_app_pixel_8`). + selector: + text: {} + cooldown_minutes: + name: Cooldown (minutes) + description: Suppress repeat alerts within this window. + default: 15 + selector: + number: + min: 0 + max: 240 + unit_of_measurement: minutes + +mode: single +max_exceeded: silent + +trigger: + - platform: state + entity_id: !input distress_entity + from: "off" + to: "on" + +action: + - service: !input notify_target + data: + title: "⚠️ Possible distress detected" + message: > + RuView flagged sustained elevated heart rate + agitated motion in + {{ state_attr(trigger.entity_id, 'friendly_name') or trigger.entity_id }}. + Reason: {{ state_attr(trigger.entity_id, 'reason') or 'none provided' }}. + - delay: + minutes: !input cooldown_minutes diff --git a/examples/ha-blueprints/02-dim-hallway-when-sleeping.yaml b/examples/ha-blueprints/02-dim-hallway-when-sleeping.yaml new file mode 100644 index 00000000..8d53cd82 --- /dev/null +++ b/examples/ha-blueprints/02-dim-hallway-when-sleeping.yaml @@ -0,0 +1,52 @@ +blueprint: + name: RuView — dim hallway when someone sleeping + description: > + Drop hallway lights to a configurable brightness when anyone in the + bedroom is in the someone_sleeping state. A midnight bathroom trip + doesn't blast full lights. Restores when sleeping flips off. + Part of the ADR-115 §3.12 starter blueprint set. + domain: automation + source_url: https://github.com/ruvnet/RuView/blob/main/examples/ha-blueprints/02-dim-hallway-when-sleeping.yaml + input: + sleeping_entity: + name: Someone sleeping binary_sensor + description: The `binary_sensor.*_someone_sleeping` entity published by RuView. + selector: + entity: + domain: binary_sensor + hallway_light: + name: Hallway light + selector: + entity: + domain: light + sleep_brightness: + name: Brightness while sleeping (%) + default: 10 + selector: + number: + min: 1 + max: 100 + unit_of_measurement: "%" + +mode: single + +trigger: + - platform: state + entity_id: !input sleeping_entity + +action: + - choose: + - conditions: + - condition: state + entity_id: !input sleeping_entity + state: "on" + sequence: + - service: light.turn_on + target: + entity_id: !input hallway_light + data: + brightness_pct: !input sleep_brightness + default: + - service: light.turn_off + target: + entity_id: !input hallway_light diff --git a/examples/ha-blueprints/03-wake-routine-on-bed-exit.yaml b/examples/ha-blueprints/03-wake-routine-on-bed-exit.yaml new file mode 100644 index 00000000..b6c063dd --- /dev/null +++ b/examples/ha-blueprints/03-wake-routine-on-bed-exit.yaml @@ -0,0 +1,74 @@ +blueprint: + name: RuView — wake-up routine on bed exit + description: > + When bed_exit fires in the morning window, ramp bedroom lights over + a configurable duration, start the coffee maker, and disarm the + home alarm. Time-window-gated so a midnight bathroom trip doesn't + trigger it. Part of the ADR-115 §3.12 starter blueprint set. + domain: automation + source_url: https://github.com/ruvnet/RuView/blob/main/examples/ha-blueprints/03-wake-routine-on-bed-exit.yaml + input: + bed_exit_event: + name: Bed exit event entity + selector: + entity: + domain: event + bedroom_light: + name: Bedroom light + selector: + entity: + domain: light + coffee_maker: + name: Coffee maker switch + selector: + entity: + domain: switch + home_alarm: + name: Home alarm control panel + selector: + entity: + domain: alarm_control_panel + window_start: + name: Morning window start (hh:mm) + default: "05:00:00" + selector: + time: {} + window_end: + name: Morning window end (hh:mm) + default: "09:00:00" + selector: + time: {} + ramp_seconds: + name: Light ramp duration (seconds) + default: 600 + selector: + number: + min: 0 + max: 3600 + unit_of_measurement: s + +mode: single +max_exceeded: silent + +trigger: + - platform: state + entity_id: !input bed_exit_event + +condition: + - condition: time + after: !input window_start + before: !input window_end + +action: + - service: light.turn_on + target: + entity_id: !input bedroom_light + data: + brightness_pct: 100 + transition: !input ramp_seconds + - service: switch.turn_on + target: + entity_id: !input coffee_maker + - service: alarm_control_panel.alarm_disarm + target: + entity_id: !input home_alarm diff --git a/examples/ha-blueprints/04-alert-elderly-inactivity-anomaly.yaml b/examples/ha-blueprints/04-alert-elderly-inactivity-anomaly.yaml new file mode 100644 index 00000000..9713f2f6 --- /dev/null +++ b/examples/ha-blueprints/04-alert-elderly-inactivity-anomaly.yaml @@ -0,0 +1,70 @@ +blueprint: + name: RuView — alert on elderly inactivity anomaly + description: > + Send a high-priority push notification when elderly_inactivity_anomaly + fires — the resident has been still for unusually long given their + personal baseline. Includes a configurable secondary call/SMS escalation + via a notify group if the first alert isn't acknowledged. + Part of the ADR-115 §3.12 starter blueprint set. + domain: automation + source_url: https://github.com/ruvnet/RuView/blob/main/examples/ha-blueprints/04-alert-elderly-inactivity-anomaly.yaml + input: + anomaly_entity: + name: Elderly inactivity anomaly binary_sensor + selector: + entity: + domain: binary_sensor + primary_notify: + name: Primary notify service (e.g. carer's phone) + selector: + text: {} + escalation_notify: + name: Escalation notify service (optional) + description: Fires if anomaly stays ON after ack_timeout_min. + default: "" + selector: + text: {} + ack_timeout_min: + name: Escalation timeout (minutes) + default: 10 + selector: + number: + min: 1 + max: 120 + unit_of_measurement: minutes + +mode: single +max_exceeded: silent + +trigger: + - platform: state + entity_id: !input anomaly_entity + from: "off" + to: "on" + +action: + - service: !input primary_notify + data: + title: "🚨 Inactivity anomaly" + message: > + Resident has been still longer than usual. Check on them. + Reason: {{ state_attr(trigger.entity_id, 'reason') or 'none provided' }}. + - wait_for_trigger: + - platform: state + entity_id: !input anomaly_entity + to: "off" + timeout: + minutes: !input ack_timeout_min + continue_on_timeout: true + - choose: + - conditions: + - condition: state + entity_id: !input anomaly_entity + state: "on" + - condition: template + value_template: "{{ (escalation_notify | default('')) != '' }}" + sequence: + - service: !input escalation_notify + data: + title: "🆘 Escalation — anomaly still active" + message: "No motion for the duration of the alert window. Please intervene." diff --git a/examples/ha-blueprints/05-meeting-lights-presence-mode.yaml b/examples/ha-blueprints/05-meeting-lights-presence-mode.yaml new file mode 100644 index 00000000..d6d33d81 --- /dev/null +++ b/examples/ha-blueprints/05-meeting-lights-presence-mode.yaml @@ -0,0 +1,52 @@ +blueprint: + name: RuView — meeting lights + presence mode + description: > + When meeting_in_progress fires, set conference-room lights to a + professional white scene and switch presence-aware automations + (motion lights, ambient noise) into "meeting mode" so they don't + interrupt. Restores prior scene when meeting ends. + Part of the ADR-115 §3.12 starter blueprint set. + domain: automation + source_url: https://github.com/ruvnet/RuView/blob/main/examples/ha-blueprints/05-meeting-lights-presence-mode.yaml + input: + meeting_entity: + name: Meeting in progress binary_sensor + selector: + entity: + domain: binary_sensor + meeting_lights: + name: Meeting room lights (group) + selector: + entity: + domain: light + meeting_scene: + name: Scene to activate during meeting (e.g. scene.meeting_mode) + selector: + entity: + domain: scene + restore_scene: + name: Scene to restore after meeting (e.g. scene.room_default) + selector: + entity: + domain: scene + +mode: single + +trigger: + - platform: state + entity_id: !input meeting_entity + +action: + - choose: + - conditions: + - condition: state + entity_id: !input meeting_entity + state: "on" + sequence: + - service: scene.turn_on + target: + entity_id: !input meeting_scene + default: + - service: scene.turn_on + target: + entity_id: !input restore_scene diff --git a/examples/ha-blueprints/06-bathroom-fan-while-occupied.yaml b/examples/ha-blueprints/06-bathroom-fan-while-occupied.yaml new file mode 100644 index 00000000..05391608 --- /dev/null +++ b/examples/ha-blueprints/06-bathroom-fan-while-occupied.yaml @@ -0,0 +1,52 @@ +blueprint: + name: RuView — bathroom fan while occupied + description: > + Run the bathroom exhaust fan while bathroom_occupied is ON, with a + configurable run-on delay after the zone clears (humidity recovery). + Privacy-mode-safe: bathroom_occupied is derived from zone presence, + not biometrics, so this works under --privacy-mode too. + Part of the ADR-115 §3.12 starter blueprint set. + domain: automation + source_url: https://github.com/ruvnet/RuView/blob/main/examples/ha-blueprints/06-bathroom-fan-while-occupied.yaml + input: + bathroom_entity: + name: Bathroom occupied binary_sensor + selector: + entity: + domain: binary_sensor + fan_switch: + name: Exhaust fan switch + selector: + entity: + domain: switch + run_on_minutes: + name: Run-on after vacated (minutes) + default: 5 + selector: + number: + min: 0 + max: 60 + unit_of_measurement: minutes + +mode: restart + +trigger: + - platform: state + entity_id: !input bathroom_entity + +action: + - choose: + - conditions: + - condition: state + entity_id: !input bathroom_entity + state: "on" + sequence: + - service: switch.turn_on + target: + entity_id: !input fan_switch + default: + - delay: + minutes: !input run_on_minutes + - service: switch.turn_off + target: + entity_id: !input fan_switch diff --git a/examples/ha-blueprints/07-fall-risk-escalation.yaml b/examples/ha-blueprints/07-fall-risk-escalation.yaml new file mode 100644 index 00000000..64f0e5d7 --- /dev/null +++ b/examples/ha-blueprints/07-fall-risk-escalation.yaml @@ -0,0 +1,44 @@ +blueprint: + name: RuView — escalate on fall-risk score crossing + description: > + Send a notification when the fall_risk_elevated sensor crosses a + configurable threshold (default 70) — the resident's near-fall + frequency + gait-instability proxy has reached a level worth + investigating. Pairs with the longer-term ADR-079 P9 personalisation + flow once available. Part of the ADR-115 §3.12 starter blueprint set. + domain: automation + source_url: https://github.com/ruvnet/RuView/blob/main/examples/ha-blueprints/07-fall-risk-escalation.yaml + input: + fall_risk_entity: + name: Fall risk elevated sensor (0-100 score) + selector: + entity: + domain: sensor + notify_target: + name: Notification service + selector: + text: {} + threshold: + name: Crossing threshold + default: 70 + selector: + number: + min: 30 + max: 100 + +mode: single +max_exceeded: silent + +trigger: + - platform: numeric_state + entity_id: !input fall_risk_entity + above: !input threshold + +action: + - service: !input notify_target + data: + title: "⚠️ Fall-risk score elevated" + message: > + {{ trigger.to_state.attributes.friendly_name or trigger.entity_id }} + crossed {{ threshold }} (current value + {{ trigger.to_state.state }}). Consider a wellness check. diff --git a/examples/ha-blueprints/08-auto-arm-security-when-not-active.yaml b/examples/ha-blueprints/08-auto-arm-security-when-not-active.yaml new file mode 100644 index 00000000..7dd06639 --- /dev/null +++ b/examples/ha-blueprints/08-auto-arm-security-when-not-active.yaml @@ -0,0 +1,65 @@ +blueprint: + name: RuView — auto-arm security when room not active + description: > + Auto-arm the home alarm when room_active flips to OFF for all + monitored rooms AND no_movement is ON in the primary room. Lets the + home self-protect without requiring user input at the door. + Part of the ADR-115 §3.12 starter blueprint set. + domain: automation + source_url: https://github.com/ruvnet/RuView/blob/main/examples/ha-blueprints/08-auto-arm-security-when-not-active.yaml + input: + room_active_group: + name: Group of room_active binary_sensors (one per room) + description: A `group.*` entity containing every RuView room_active sensor. + selector: + entity: + domain: group + primary_no_movement: + name: Primary room no_movement binary_sensor + selector: + entity: + domain: binary_sensor + home_alarm: + name: Home alarm control panel + selector: + entity: + domain: alarm_control_panel + arm_mode: + name: Arm mode + default: arm_away + selector: + select: + options: + - arm_away + - arm_home + - arm_night + confirm_minutes: + name: Confirmation idle window (minutes) + default: 10 + selector: + number: + min: 1 + max: 120 + unit_of_measurement: minutes + +mode: single + +trigger: + - platform: state + entity_id: !input room_active_group + to: "off" + for: + minutes: !input confirm_minutes + +condition: + - condition: state + entity_id: !input primary_no_movement + state: "on" + - condition: state + entity_id: !input home_alarm + state: disarmed + +action: + - service: "alarm_control_panel.{{ arm_mode }}" + target: + entity_id: !input home_alarm diff --git a/examples/ha-blueprints/README.md b/examples/ha-blueprints/README.md new file mode 100644 index 00000000..bfa8728d --- /dev/null +++ b/examples/ha-blueprints/README.md @@ -0,0 +1,60 @@ +# RuView starter Home Assistant Blueprints + +8 ready-to-import HA Blueprints covering the highest-leverage automations +RuView's HA-MIND semantic primitives unlock. Drop the YAML files into +`/blueprints/automation/ruvnet/` and import from the HA UI +(**Settings → Automations & Scenes → Blueprints → Import Blueprint**). + +| # | Blueprint | Primary primitive | Use case | +|---|---------------------------------------------------------------------|------------------------------|---------------------------------------| +| 1 | [Notify on possible distress](01-notify-on-possible-distress.yaml) | `possible_distress` | Healthcare / AAL / single-occupant | +| 2 | [Dim hallway when sleeping](02-dim-hallway-when-sleeping.yaml) | `someone_sleeping` | Convenience / sleep hygiene | +| 3 | [Wake routine on bed exit](03-wake-routine-on-bed-exit.yaml) | `bed_exit` | Morning routine / smart home | +| 4 | [Alert on elderly inactivity anomaly](04-alert-elderly-inactivity-anomaly.yaml) | `elderly_inactivity_anomaly` | AAL / aging-in-place | +| 5 | [Meeting lights + presence mode](05-meeting-lights-presence-mode.yaml) | `meeting_in_progress` | Conference room / WFH | +| 6 | [Bathroom fan while occupied](06-bathroom-fan-while-occupied.yaml) | `bathroom_occupied` | Humidity / privacy-mode-safe | +| 7 | [Escalate on fall-risk crossing](07-fall-risk-escalation.yaml) | `fall_risk_elevated` | AAL / preventive intervention | +| 8 | [Auto-arm security when room not active](08-auto-arm-security-when-not-active.yaml) | `room_active` + `no_movement` | Self-arming security | + +## Verifying the YAML + +Each blueprint validates against the HA blueprint schema +(https://www.home-assistant.io/docs/blueprint/schema/). To check locally +without an HA install: + +```bash +# Requires python3 + PyYAML +for f in examples/ha-blueprints/*.yaml; do + python -c "import yaml,sys; yaml.safe_load(open('$f'))" && echo "✓ $f" || echo "✗ $f" +done +``` + +## Privacy-mode compatibility + +Five of the eight blueprints work under `--privacy-mode` (no biometrics +exposed). The other three depend on inferred states that themselves +derive from biometrics, so they still publish, but the operator should +audit before deploying in regulated contexts. + +| Blueprint | Privacy-mode safe? | +|------------------------------------------|--------------------| +| 01 Notify on possible distress | ⚠️ derives from HR/motion — state still publishes | +| 02 Dim hallway when sleeping | ⚠️ derives from BR — state still publishes | +| 03 Wake routine on bed exit | ✅ | +| 04 Alert on elderly inactivity anomaly | ✅ | +| 05 Meeting lights | ✅ | +| 06 Bathroom fan while occupied | ✅ zone-derived only | +| 07 Escalate on fall-risk crossing | ⚠️ derives from motion-variance — state still publishes | +| 08 Auto-arm security | ✅ | + +The "⚠️" markers are the inferred-state-vs-raw-value distinction from +[ADR-115 §3.12.3](../../docs/adr/ADR-115-home-assistant-integration.md#3123-why-these-specific-primitives): +the *state* (e.g. `binary_sensor.someone_sleeping`) crosses the wire +even in privacy mode because it's derived server-side, but it's no +longer accompanied by the raw biometric values. + +## See also + +- [ADR-115](../../docs/adr/ADR-115-home-assistant-integration.md) — full design +- [`docs/integrations/home-assistant.md`](../../docs/integrations/home-assistant.md) — operator guide +- [`docs/integrations/semantic-primitives-metrics.md`](../../docs/integrations/semantic-primitives-metrics.md) — per-primitive F1 diff --git a/scripts/validate-ha-blueprints.py b/scripts/validate-ha-blueprints.py new file mode 100644 index 00000000..7285d6c9 --- /dev/null +++ b/scripts/validate-ha-blueprints.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Validate every YAML file under examples/ha-blueprints/. + +HA Blueprints use the `!input` YAML tag, which stock PyYAML doesn't +know how to construct. We register a no-op constructor for it so we +can still safe_load the files and assert on their structure. + +Exits 0 if all blueprints are well-formed, non-zero otherwise. Intended +to run in CI on every PR that touches examples/ha-blueprints/. + +Usage: + python scripts/validate-ha-blueprints.py +""" + +from __future__ import annotations + +import glob +import sys +from pathlib import Path + +import yaml + + +class InputTag(str): + """No-op holder for HA `!input` markers — we don't expand them, just + verify the file parses.""" + + +def _input_constructor(loader, node): + return InputTag(loader.construct_scalar(node)) + + +def _secret_constructor(loader, node): + return f"" + + +yaml.SafeLoader.add_constructor("!input", _input_constructor) +yaml.SafeLoader.add_constructor("!secret", _secret_constructor) + + +REQUIRED_BLUEPRINT_KEYS = {"name", "description", "domain"} +ALLOWED_DOMAINS = {"automation", "script"} + + +def validate(path: Path) -> list[str]: + """Return a list of issues; empty list means the blueprint is valid.""" + issues: list[str] = [] + try: + with path.open(encoding="utf-8") as fh: + doc = yaml.safe_load(fh) + except yaml.YAMLError as e: + return [f"YAML parse error: {e}"] + except OSError as e: + return [f"could not open: {e}"] + + if not isinstance(doc, dict): + return ["top-level must be a mapping"] + + bp = doc.get("blueprint") + if not isinstance(bp, dict): + issues.append("missing `blueprint` mapping at top level") + return issues + + missing = REQUIRED_BLUEPRINT_KEYS - bp.keys() + if missing: + issues.append(f"missing blueprint keys: {', '.join(sorted(missing))}") + + domain = bp.get("domain") + if domain not in ALLOWED_DOMAINS: + issues.append( + f"unsupported blueprint.domain={domain!r}; allowed: {ALLOWED_DOMAINS}" + ) + + if not isinstance(bp.get("input"), dict) or not bp["input"]: + issues.append("blueprint.input must declare at least one input") + + # The automation body must contain at least one of: trigger, + # action, sequence (script body). + if "trigger" not in doc and "action" not in doc and "sequence" not in doc: + issues.append( + "no `trigger`/`action`/`sequence` block — blueprint can't fire" + ) + + return issues + + +def main() -> int: + root = Path(__file__).resolve().parent.parent + files = sorted(glob.glob(str(root / "examples" / "ha-blueprints" / "*.yaml"))) + if not files: + print("ERROR: no blueprint YAML files found", file=sys.stderr) + return 2 + + fails = 0 + for f in files: + issues = validate(Path(f)) + rel = Path(f).relative_to(root) + if issues: + fails += 1 + print(f"FAIL {rel}") + for i in issues: + print(f" {i}") + else: + print(f"ok {rel}") + + if fails: + print(f"\n{fails} blueprint(s) failed validation", file=sys.stderr) + return 1 + print(f"\nAll {len(files)} HA Blueprints validate OK") + return 0 + + +if __name__ == "__main__": + sys.exit(main())