This commit is contained in:
Joshua King
2026-10-19 16:10:24 -04:00
parent da375a2cd7
commit 875d328c6f
4 changed files with 250 additions and 5 deletions
+31 -5
View File
@@ -73,12 +73,12 @@ actions:
## Automation Blueprints ## Automation Blueprints
NestQuest ships four automation blueprints in NestQuest ships five automation blueprints in
`custom_components/nestquest/blueprints/automation/`. Import them in `custom_components/nestquest/blueprints/automation/`. Import them in
Home Assistant under **Settings → Automations & Scenes → Blueprints** Home Assistant under **Settings → Automations & Scenes → Blueprints**
(or place the files in your `config/blueprints/automation/` folder), (or place the files in your `config/blueprints/automation/` folder),
then create one automation per blueprint, filling the inputs with your then create automations from the blueprints, filling the inputs with your
household's choices. household's choices. The UniFi blueprint needs one automation per child.
### Wiring to the integration's notification settings ### 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 sweep and reads the same day's data — do not schedule it at the
day-rollover time. day-rollover time.
### The four blueprints ### The five blueprints
1. **Morning summary** (`morning_summary.yaml`) — at the configured 1. **Morning summary** (`morning_summary.yaml`) — at the configured
time, lists every child whose `binary_sensor.nestquest_<child>_present_today` time, lists every child whose `binary_sensor.nestquest_<child>_present_today`
@@ -128,10 +128,36 @@ day-rollover time.
not re-fire, but a second child clearing the same day still not re-fire, but a second child clearing the same day still
celebrates. 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 ### Example package
```yaml ```yaml
# One automation per blueprint — Settings → Automations & Scenes → # Create automations from blueprints — Settings → Automations & Scenes →
# Create → Blueprint, then pick each NestQuest blueprint. # Create → Blueprint, then pick each NestQuest blueprint.
# #
# morning_summary: morning_time 08:00, notify_target notify.mobile_app_your_phone # morning_summary: morning_time 08:00, notify_target notify.mobile_app_your_phone
@@ -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 }}"
Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

+123
View File
@@ -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
)