Compare commits
2
Commits
9eb5b34068
...
b5a69c8c69
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5a69c8c69 | ||
|
|
875d328c6f |
@@ -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_<child>_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
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user