updates
This commit is contained in:
@@ -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 |
@@ -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