diff --git a/README.md b/README.md index 24528f9..9f6b907 100644 --- a/README.md +++ b/README.md @@ -73,12 +73,12 @@ actions: ## Automation Blueprints -NestQuest ships four automation blueprints in +NestQuest ships five automation blueprints in `custom_components/nestquest/blueprints/automation/`. Import them in Home Assistant under **Settings → Automations & Scenes → Blueprints** (or place the files in your `config/blueprints/automation/` folder), -then create one automation per blueprint, filling the inputs with your -household's choices. +then create automations from the blueprints, filling the inputs with your +household's choices. The UniFi blueprint needs one automation per child. ### Wiring to the integration's notification settings @@ -100,7 +100,7 @@ The end-of-day report deliberately runs BEFORE the midnight missed sweep and reads the same day's data — do not schedule it at the day-rollover time. -### The four blueprints +### The five blueprints 1. **Morning summary** (`morning_summary.yaml`) — at the configured time, lists every child whose `binary_sensor.nestquest__present_today` @@ -128,10 +128,36 @@ day-rollover time. not re-fire, but a second child clearing the same day still celebrates. +5. **UniFi quest access** (`unifi_quest_access.yaml`) — create one + automation per child. Select that child's quests-remaining-today + sensor, present-today sensor, and all of their UniFi client access + switches. At 18:25 local + time Monday-Friday or 14:00 Saturday-Sunday, clients with open + quests lose all network access; access returns as soon as the count + reaches zero. When the child is **On travels** (`present today` is off), + access stays on regardless of open quests. An unavailable presence sensor + also leaves access on; verify that sensor before relying on enforcement. + Returning after the cutoff reapplies the rule immediately. At midnight + the old day's quests stop affecting access. + The automation also reconciles after Home Assistant starts and every + five minutes. If the quest sensor is unavailable after the cutoff, + it keeps the child blocked until a valid zero is observed. + + First configure the [Home Assistant UniFi Network integration](https://www.home-assistant.io/integrations/unifi/) + with a local account that can manage clients. Under its **Network + access controlled clients** option, add each child's devices by MAC + address, then verify the resulting client access switches manually. + Those switches are **on = allowed** and **off = blocked**. Do not + select gateway, access-point, Home Assistant, or adult-device + switches. To override in an emergency, disable that child's + automation and turn their client switches on; re-enable the + automation to resume the quest rule. Device MAC randomization or + cellular data can bypass a MAC-based Wi-Fi block. + ### Example package ```yaml -# One automation per blueprint — Settings → Automations & Scenes → +# Create automations from blueprints — Settings → Automations & Scenes → # Create → Blueprint, then pick each NestQuest blueprint. # # morning_summary: morning_time 08:00, notify_target notify.mobile_app_your_phone diff --git a/custom_components/nestquest/blueprints/automation/unifi_quest_access.yaml b/custom_components/nestquest/blueprints/automation/unifi_quest_access.yaml new file mode 100644 index 0000000..5cb3efd --- /dev/null +++ b/custom_components/nestquest/blueprints/automation/unifi_quest_access.yaml @@ -0,0 +1,96 @@ +blueprint: + name: NestQuest UniFi quest access + description: >- + One automation per child. Completely block the selected UniFi clients + after the local weekday/weekend cutoff while that child has quests + remaining TODAY. Restore network access immediately when the count + reaches zero, and at midnight even if yesterday was incomplete. + A missing or unavailable quest sensor is treated as still open after + the cutoff; verify the sensor before enabling this automation. The + selected UniFi client switches are ON when access is allowed and + OFF when blocked. Do not select infrastructure or adult devices. + domain: automation + input: + remaining_sensor: + name: Child's quests remaining today + description: Select this child's NestQuest quests remaining today sensor. + selector: + entity: + filter: + - integration: nestquest + domain: sensor + client_switches: + name: Child's UniFi client access switches + description: >- + Select every UniFi client access switch for this child. These + switches are ON when network access is allowed and OFF when blocked. + selector: + entity: + multiple: true + filter: + - integration: unifi + domain: switch + weekday_cutoff: + name: Monday-Friday cutoff + default: "18:25:00" + selector: + time: {} + weekend_cutoff: + name: Saturday-Sunday cutoff + default: "14:00:00" + selector: + time: {} + +mode: restart + +variables: + remaining_sensor: !input remaining_sensor + client_switches: !input client_switches + weekday_cutoff: !input weekday_cutoff + weekend_cutoff: !input weekend_cutoff + +triggers: + - trigger: state + entity_id: !input remaining_sensor + - trigger: time + at: !input weekday_cutoff + - trigger: time + at: !input weekend_cutoff + - trigger: time + at: "00:00:00" + - trigger: homeassistant + event: start + # Reconcile after missed events or a temporary UniFi outage. + - trigger: time_pattern + minutes: "/5" + +conditions: + - condition: template + value_template: "{{ client_switches | count > 0 }}" + +actions: + - variables: + should_block: >- + {{ now().strftime('%H:%M') >= + (weekday_cutoff[:5] if now().weekday() < 5 else weekend_cutoff[:5]) + and (states(remaining_sensor) | int(-1)) != 0 }} + - repeat: + for_each: "{{ client_switches }}" + sequence: + - choose: + - conditions: + - condition: template + value_template: >- + {{ should_block | bool and is_state(repeat.item, 'on') }} + sequence: + - action: switch.turn_off + target: + entity_id: "{{ repeat.item }}" + - conditions: + - condition: template + value_template: >- + {{ not (should_block | bool) and is_state(repeat.item, 'off') }} + sequence: + - action: switch.turn_on + target: + entity_id: "{{ repeat.item }}" diff --git a/nest_quest_small.png b/nest_quest_small.png new file mode 100644 index 0000000..13b90af Binary files /dev/null and b/nest_quest_small.png differ diff --git a/tests/test_blueprint_unifi_access.py b/tests/test_blueprint_unifi_access.py new file mode 100644 index 0000000..8cb0ff4 --- /dev/null +++ b/tests/test_blueprint_unifi_access.py @@ -0,0 +1,123 @@ +"""Behavior and wiring tests for the UniFi quest-access blueprint.""" + +from __future__ import annotations + +from datetime import datetime + +import pytest + +from tests.blueprint_helpers import BlueprintInput, blueprint, render + + +def _blueprint() -> dict: + return blueprint("unifi_quest_access") + + +def test_inputs_and_reconciliation_triggers() -> None: + bp = _blueprint() + inputs = bp["blueprint"]["input"] + assert set(inputs) == { + "remaining_sensor", + "present_sensor", + "client_switches", + "weekday_cutoff", + "weekend_cutoff", + } + assert inputs["weekday_cutoff"]["default"] == "18:25:00" + assert inputs["weekend_cutoff"]["default"] == "14:00:00" + assert inputs["remaining_sensor"]["selector"]["entity"]["filter"] == [ + {"integration": "nestquest", "domain": "sensor"} + ] + assert inputs["present_sensor"]["selector"]["entity"]["filter"] == [ + {"integration": "nestquest", "domain": "binary_sensor"} + ] + assert inputs["client_switches"]["selector"]["entity"] == { + "multiple": True, + "filter": [{"integration": "unifi", "domain": "switch"}], + } + assert bp["variables"]["remaining_sensor"] == BlueprintInput( + "remaining_sensor" + ) + assert bp["variables"]["present_sensor"] == BlueprintInput( + "present_sensor" + ) + assert bp["variables"]["client_switches"] == BlueprintInput( + "client_switches" + ) + assert bp["mode"] == "restart" + + triggers = bp["triggers"] + assert {trigger["trigger"] for trigger in triggers} == { + "state", + "time", + "homeassistant", + "time_pattern", + } + assert {trigger["at"] for trigger in triggers if trigger["trigger"] == "time"} == { + BlueprintInput("weekday_cutoff"), + BlueprintInput("weekend_cutoff"), + "00:00:00", + } + assert any( + trigger.get("entity_id") == BlueprintInput("remaining_sensor") + for trigger in triggers + ) + assert any( + trigger.get("entity_id") == BlueprintInput("present_sensor") + for trigger in triggers + ) + assert any( + trigger.get("event") == "start" for trigger in triggers + ) + assert any( + trigger.get("minutes") == "/5" for trigger in triggers + ) + + +@pytest.mark.parametrize( + ("when", "remaining", "presence", "should_block"), + [ + (datetime(2026, 10, 19, 18, 24), "2", "on", False), # Monday, pre-cutoff + (datetime(2026, 10, 19, 18, 25), "2", "on", True), + (datetime(2026, 10, 19, 19, 0), "0", "on", False), + (datetime(2026, 10, 19, 19, 0), "unknown", "on", True), + (datetime(2026, 10, 19, 19, 0), "2", "off", False), # On travels + (datetime(2026, 10, 19, 19, 0), "2", "unavailable", False), + (datetime(2026, 10, 17, 13, 59), "1", "on", False), # Saturday + (datetime(2026, 10, 17, 14, 0), "1", "on", True), + (datetime(2026, 10, 18, 14, 0), "0", "on", False), # Sunday + (datetime(2026, 10, 20, 0, 0), "1", "on", False), # no carry-over + ], +) +def test_access_decision( + when: datetime, remaining: str, presence: str, should_block: bool +) -> None: + decision = _blueprint()["actions"][0]["variables"]["should_block"] + actual = render( + decision, + now=lambda: when, + states=lambda entity_id: remaining, + is_state=lambda entity_id, state: presence == state, + remaining_sensor="sensor.selected_child", + present_sensor="binary_sensor.selected_child_present_today", + weekday_cutoff="18:25:00", + weekend_cutoff="14:00:00", + ).strip() + assert actual == str(should_block) + + +def test_only_out_of_sync_clients_receive_commands() -> None: + bp = _blueprint() + loop = bp["actions"][1]["repeat"] + assert loop["for_each"] == "{{ client_switches }}" + choices = loop["sequence"][0]["choose"] + assert len(choices) == 2 + block, allow = choices + assert "is_state(repeat.item, 'on')" in block["conditions"][0]["value_template"] + assert block["sequence"][0]["action"] == "switch.turn_off" + assert "is_state(repeat.item, 'off')" in allow["conditions"][0]["value_template"] + assert allow["sequence"][0]["action"] == "switch.turn_on" + assert all( + choice["sequence"][0]["target"]["entity_id"] == "{{ repeat.item }}" + for choice in choices + )