Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec863f49ab |
@@ -1,7 +1,3 @@
|
||||
<p align="center">
|
||||
<img src="design/logos/NestQuest-NoBG.png" alt="NestQuest logo" width="200">
|
||||
</p>
|
||||
|
||||
# NestQuest - House Chaos Coordination
|
||||
|
||||
Home Assistant custom integration for managing chores, custody schedules, and house coordination.
|
||||
@@ -73,12 +69,12 @@ actions:
|
||||
|
||||
## Automation Blueprints
|
||||
|
||||
NestQuest ships five automation blueprints in
|
||||
NestQuest ships four 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 automations from the blueprints, filling the inputs with your
|
||||
household's choices. The UniFi blueprint needs one automation per child.
|
||||
then create one automation per blueprint, filling the inputs with your
|
||||
household's choices.
|
||||
|
||||
### Wiring to the integration's notification settings
|
||||
|
||||
@@ -100,7 +96,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 five blueprints
|
||||
### The four blueprints
|
||||
|
||||
1. **Morning summary** (`morning_summary.yaml`) — at the configured
|
||||
time, lists every child whose `binary_sensor.nestquest_<child>_present_today`
|
||||
@@ -128,36 +124,10 @@ 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
|
||||
# Create automations from blueprints — Settings → Automations & Scenes →
|
||||
# One automation per blueprint — Settings → Automations & Scenes →
|
||||
# Create → Blueprint, then pick each NestQuest blueprint.
|
||||
#
|
||||
# morning_summary: morning_time 08:00, notify_target notify.mobile_app_your_phone
|
||||
|
||||
@@ -1,31 +1,5 @@
|
||||
# NestQuest Release Notes
|
||||
|
||||
## Version 0.10.3 — 2026-10-06
|
||||
|
||||
### Scope
|
||||
|
||||
Patch release carrying the admin PWA desktop-icon update onto `main` (PR #301,
|
||||
task 55e1e687).
|
||||
|
||||
### Behaviour
|
||||
|
||||
- The admin PWA's installable icons (192, 512, and 512 maskable) are now
|
||||
generated from `design/logos/NestQuest_Logo.png` instead of placeholder
|
||||
graphics, so a home-screen install of the admin app shows the NestQuest logo.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Home Assistant floor 2024.6.0 (unchanged).
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- None. Schema unchanged at version 9; no migration, no database backup
|
||||
required.
|
||||
|
||||
### Rollback
|
||||
|
||||
- No schema change: rolling back to 0.10.2 requires no database restore.
|
||||
|
||||
## Version 0.10.2 — 2026-10-06
|
||||
|
||||
### Scope
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
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.
|
Before Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 191 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 191 KiB |
@@ -15,5 +15,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"issue_tracker": "https://github.com/Maniacs-Mansion/NestQuest/issues",
|
||||
"requirements": [],
|
||||
"version": "0.10.3"
|
||||
"version": "0.10.2"
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
# NestQuest brand icons in Home Assistant
|
||||
|
||||
## Where the icon comes from
|
||||
|
||||
Since Home Assistant 2026.3, a custom integration can ship its own brand
|
||||
images in `custom_components/<domain>/brand/`, and Home Assistant serves them
|
||||
ahead of the brands CDN (`brands.home-assistant.io`). NestQuest ships:
|
||||
|
||||
| File | Size |
|
||||
|------|------|
|
||||
| `custom_components/nestquest/brand/icon.png` | 256×256 |
|
||||
| `custom_components/nestquest/brand/icon@2x.png` | 512×512 |
|
||||
| `custom_components/nestquest/brand/logo.png` | 256×256 |
|
||||
| `custom_components/nestquest/brand/logo@2x.png` | 512×512 |
|
||||
|
||||
HACS installs the whole `custom_components/nestquest/` directory, so these
|
||||
images arrive with the integration. On Home Assistant 2026.3 or newer they
|
||||
appear on the integrations page and the integration tile, through Home
|
||||
Assistant's brand endpoint. Older Home Assistant versions ignore the directory
|
||||
and show the generic placeholder icon.
|
||||
|
||||
The `custom_integrations` folder of the `home-assistant/brands` repository is
|
||||
legacy for this purpose; NestQuest does not depend on it.
|
||||
|
||||
## Known caveat: the HACS store card
|
||||
|
||||
The HACS store card (the repository listing inside HACS, shown before and
|
||||
after installing) is rendered by HACS itself, not by the integration. It may
|
||||
still take its image from the brands CDN or fall back to the avatar of the
|
||||
repository owner (the `Maniacs-Mansion` organization). Whether HACS reads the
|
||||
local `brand/` directory for that card has not been verified, so that icon
|
||||
may stay generic. The repository page inside HACS shows the README, which
|
||||
embeds the logo from `design/logos/NestQuest-NoBG.png`.
|
||||
|
||||
## Regenerating
|
||||
|
||||
The PNGs are generated offline and deterministically (pure Node, zlib only)
|
||||
from `design/logos/NestQuest-NoBG.png`, box-filter downscaled with
|
||||
transparency kept. From the repository root:
|
||||
|
||||
```bash
|
||||
node design/logos/generate-brand-icons.mjs # write the four PNGs
|
||||
node design/logos/generate-brand-icons.mjs --check # exit 1 if any drifted
|
||||
```
|
||||
|
||||
`tests/test_brand_icons.py` runs the `--check` and asserts the four files
|
||||
exist with the expected sizes, so a missing or hand-edited image fails the
|
||||
test suite.
|
||||
@@ -1,187 +0,0 @@
|
||||
// Generates the integration's local brand images in
|
||||
// custom_components/nestquest/brand/ (read by Home Assistant 2026.3+) from the
|
||||
// transparent logo (design/logos/NestQuest-NoBG.png), box-filter downscaled
|
||||
// with alpha kept. Adapted from admin/scripts/generate-icons.mjs for RGBA.
|
||||
// Pure Node (zlib only) — no network, no image dependencies.
|
||||
// Run (from the repository root):
|
||||
// node design/logos/generate-brand-icons.mjs # write the PNGs
|
||||
// node design/logos/generate-brand-icons.mjs --check # exit 1 if any drifted
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { deflateSync, inflateSync } from "node:zlib";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
return c >>> 0;
|
||||
});
|
||||
|
||||
function crc32(buf) {
|
||||
let c = 0xffffffff;
|
||||
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length);
|
||||
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(body));
|
||||
return Buffer.concat([len, body, crc]);
|
||||
}
|
||||
|
||||
function encodePng(size, rgba) {
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(size, 0);
|
||||
ihdr.writeUInt32BE(size, 4);
|
||||
ihdr[8] = 8; // bit depth
|
||||
ihdr[9] = 6; // colour type: RGBA
|
||||
const stride = size * 4;
|
||||
const raw = Buffer.alloc(size * (stride + 1));
|
||||
for (let y = 0; y < size; y++) {
|
||||
raw[y * (stride + 1)] = 0; // filter: none
|
||||
raw.set(rgba.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
|
||||
}
|
||||
return Buffer.concat([
|
||||
SIG,
|
||||
chunk("IHDR", ihdr),
|
||||
chunk("IDAT", deflateSync(raw, { level: 9 })),
|
||||
chunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
// Minimal decoder for the source logo: 8-bit RGBA, non-interlaced only.
|
||||
// Anything else fails loudly rather than producing a wrong icon.
|
||||
function decodePng(buf) {
|
||||
if (!buf.subarray(0, 8).equals(SIG)) throw new Error("not a PNG");
|
||||
let width, height;
|
||||
const idat = [];
|
||||
for (let off = 8; off < buf.length; ) {
|
||||
const len = buf.readUInt32BE(off);
|
||||
const type = buf.toString("ascii", off + 4, off + 8);
|
||||
const data = buf.subarray(off + 8, off + 8 + len);
|
||||
if (type === "IHDR") {
|
||||
width = data.readUInt32BE(0);
|
||||
height = data.readUInt32BE(4);
|
||||
const [depth, colour, , , interlace] = data.subarray(8);
|
||||
if (depth !== 8 || colour !== 6 || interlace !== 0) {
|
||||
throw new Error(`unsupported PNG: depth ${depth}, colour type ${colour}, interlace ${interlace}`);
|
||||
}
|
||||
} else if (type === "IDAT") idat.push(data);
|
||||
else if (type === "IEND") break;
|
||||
off += 12 + len;
|
||||
}
|
||||
const raw = inflateSync(Buffer.concat(idat));
|
||||
const stride = width * 4;
|
||||
const px = Buffer.alloc(stride * height);
|
||||
for (let y = 0; y < height; y++) {
|
||||
const filter = raw[y * (stride + 1)];
|
||||
const src = y * (stride + 1) + 1;
|
||||
const dst = y * stride;
|
||||
for (let i = 0; i < stride; i++) {
|
||||
const a = i >= 4 ? px[dst + i - 4] : 0;
|
||||
const b = y > 0 ? px[dst - stride + i] : 0;
|
||||
const c = i >= 4 && y > 0 ? px[dst - stride + i - 4] : 0;
|
||||
let p;
|
||||
if (filter === 0) p = 0;
|
||||
else if (filter === 1) p = a;
|
||||
else if (filter === 2) p = b;
|
||||
else if (filter === 3) p = (a + b) >> 1;
|
||||
else if (filter === 4) {
|
||||
const pa = Math.abs(b - c), pb = Math.abs(a - c), pc = Math.abs(a + b - 2 * c);
|
||||
p = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
||||
} else throw new Error(`bad PNG filter ${filter} on row ${y}`);
|
||||
px[dst + i] = (raw[src + i] + p) & 0xff;
|
||||
}
|
||||
}
|
||||
if (width !== height) throw new Error(`logo must be square, got ${width}x${height}`);
|
||||
return { width, height, px };
|
||||
}
|
||||
|
||||
// Box-filter (area-average) resize along one axis: each output sample is the
|
||||
// coverage-weighted mean of the source samples it spans.
|
||||
function boxWeights(srcLen, dstLen) {
|
||||
const scale = srcLen / dstLen;
|
||||
return Array.from({ length: dstLen }, (_, o) => {
|
||||
const start = o * scale, end = start + scale;
|
||||
const taps = [];
|
||||
for (let s = Math.floor(start); s < Math.min(Math.ceil(end), srcLen); s++) {
|
||||
taps.push([s, (Math.min(end, s + 1) - Math.max(start, s)) / scale]);
|
||||
}
|
||||
return taps;
|
||||
});
|
||||
}
|
||||
|
||||
// Colour is averaged premultiplied by alpha, so fully transparent source
|
||||
// pixels (whatever RGB they carry) cannot bleed a fringe into the edges.
|
||||
function resize({ width, height, px }, size) {
|
||||
const wx = boxWeights(width, size);
|
||||
const wy = boxWeights(height, size);
|
||||
const tmp = new Float64Array(size * height * 4); // horizontal pass, premultiplied
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const t = (y * size + x) * 4;
|
||||
for (const [s, w] of wx[x]) {
|
||||
const p = (y * width + s) * 4;
|
||||
const aw = px[p + 3] * w;
|
||||
for (let k = 0; k < 3; k++) tmp[t + k] += px[p + k] * aw;
|
||||
tmp[t + 3] += aw;
|
||||
}
|
||||
}
|
||||
}
|
||||
const out = new Uint8Array(size * size * 4); // vertical pass, un-premultiply
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const acc = [0, 0, 0, 0];
|
||||
for (const [s, w] of wy[y]) {
|
||||
const t = (s * size + x) * 4;
|
||||
for (let k = 0; k < 4; k++) acc[k] += tmp[t + k] * w;
|
||||
}
|
||||
const o = (y * size + x) * 4;
|
||||
const alpha = Math.min(255, Math.round(acc[3]));
|
||||
for (let k = 0; k < 3; k++) {
|
||||
out[o + k] = alpha === 0 ? 0 : Math.min(255, Math.round(acc[k] / acc[3]));
|
||||
}
|
||||
out[o + 3] = alpha;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const logo = decodePng(readFileSync(new URL("NestQuest-NoBG.png", import.meta.url)));
|
||||
const outDir = new URL("../../custom_components/nestquest/brand/", import.meta.url);
|
||||
const files = [
|
||||
["icon.png", 256],
|
||||
["icon@2x.png", 512],
|
||||
["logo.png", 256],
|
||||
["logo@2x.png", 512],
|
||||
];
|
||||
const check = process.argv.includes("--check");
|
||||
let drifted = false;
|
||||
if (!check) mkdirSync(outDir, { recursive: true });
|
||||
for (const [name, size] of files) {
|
||||
const path = fileURLToPath(new URL(name, outDir));
|
||||
const png = encodePng(size, resize(logo, size));
|
||||
if (check) {
|
||||
let current = null;
|
||||
try {
|
||||
current = readFileSync(path);
|
||||
} catch {
|
||||
// missing counts as drift
|
||||
}
|
||||
if (!current || !current.equals(png)) {
|
||||
drifted = true;
|
||||
console.error(`drift: ${path} differs from a fresh generation`);
|
||||
}
|
||||
} else {
|
||||
writeFileSync(path, png);
|
||||
console.log(`wrote ${path}`);
|
||||
}
|
||||
}
|
||||
if (drifted) {
|
||||
console.error("run `node design/logos/generate-brand-icons.mjs` and commit the result");
|
||||
process.exit(1);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 375 KiB |
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nestquest"
|
||||
version = "0.10.3"
|
||||
version = "0.10.2"
|
||||
description = "NestQuest Home Assistant Integration"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
"""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
|
||||
)
|
||||
@@ -1,55 +0,0 @@
|
||||
"""The integration's local brand images (Home Assistant 2026.3+).
|
||||
|
||||
``custom_components/nestquest/brand/`` ships through HACS with the
|
||||
integration, and Home Assistant serves those images ahead of the brands CDN.
|
||||
They are generated offline from ``design/logos/NestQuest-NoBG.png`` by
|
||||
``design/logos/generate-brand-icons.mjs``; these checks fail if the directory
|
||||
goes missing, changes shape, or drifts from a fresh generation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
BRAND_DIR = REPO_ROOT / "custom_components" / "nestquest" / "brand"
|
||||
GENERATOR = REPO_ROOT / "design" / "logos" / "generate-brand-icons.mjs"
|
||||
EXPECTED = {
|
||||
"icon.png": 256,
|
||||
"icon@2x.png": 512,
|
||||
"logo.png": 256,
|
||||
"logo@2x.png": 512,
|
||||
}
|
||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
def test_brand_dir_holds_exactly_the_four_images() -> None:
|
||||
assert sorted(p.name for p in BRAND_DIR.iterdir()) == sorted(EXPECTED)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("name", "size"), sorted(EXPECTED.items()))
|
||||
def test_brand_image_is_square_rgba_png(name: str, size: int) -> None:
|
||||
data = (BRAND_DIR / name).read_bytes()
|
||||
assert data[:8] == PNG_SIGNATURE
|
||||
assert data[12:16] == b"IHDR"
|
||||
width, height, depth, colour = struct.unpack(">IIBB", data[16:26])
|
||||
assert (width, height) == (size, size)
|
||||
assert (depth, colour) == (8, 6) # 8-bit RGBA keeps the transparency
|
||||
|
||||
|
||||
def test_generator_reproduces_the_committed_images() -> None:
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
pytest.skip("needs node")
|
||||
result = subprocess.run(
|
||||
[node, str(GENERATOR), "--check"],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
Reference in New Issue
Block a user