docs(home-assistant): one room, intervals that divide the hour, current keys

The room-climate guide said both to create one automation per room and
to track only one; it now recommends the bedroom and says why a second
room cannot work (no room name, same-minute pushes collide on 409). The
blueprint offers 5, 10, 15, 20, 30 or 60 minutes, so the push lands on
the stated minutes, and it and the guide's examples use the triggers,
conditions and actions keys of Home Assistant 2024.10 and later. A test
holds all three.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 22:00:41 +02:00
1 parent e8f34315d4
commit ac149460ba
3 files changed
+136 -30

No files matched your search

@@ -1,5 +1,8 @@
# Push a room's CO2, temperature and humidity to HealthLog every few minutes.
#
# Use it for one room. The readings carry no room name, so a second room would
# land in the same series, and a push in the same minute is refused (409).
#
# Needs the `healthlog_room_climate` rest_command from
# docs/integrations/home-assistant.md ("Room climate") and a measurement
# ingest token. Every sensor is optional; a sensor that reports `unknown`,
@@ -12,6 +15,8 @@ blueprint:
HealthLog through a measurement ingest token. HealthLog keeps each
reading and shows the day's mean, low and high.
domain: automation
homeassistant:
min_version: 2024.10.0
source_url: https://github.com/MBombeck/HealthLog/blob/main/docs/integrations/blueprints/healthlog-room-climate.yaml
input:
co2_sensor:
@@ -40,14 +45,13 @@ blueprint:
device_class: humidity
interval_minutes:
name: Interval
description: Minutes between pushes. 15 is plenty for a daily picture.
default: 15
description: >-
Minutes between pushes, on the hour and every interval after it.
15 is plenty for a daily picture.
default: "15"
selector:
number:
min: 5
max: 60
step: 5
unit_of_measurement: min
select:
options: ["5", "10", "15", "20", "30", "60"]
mode: single
@@ -77,17 +81,17 @@ variables:
{% endfor %}
{{ ns.items }}
trigger:
- platform: time_pattern
triggers:
- trigger: time_pattern
minutes: "/5"
condition:
conditions:
- condition: template
value_template: >-
{{ now().minute % (interval_minutes | int(15)) == 0
and readings | count > 0 }}
action:
- service: rest_command.healthlog_room_climate
actions:
- action: rest_command.healthlog_room_climate
data:
readings: "{{ readings }}"
+21 -18
View File
@@ -109,20 +109,20 @@ a new weight:
```yaml
automation:
- alias: "Push weight to HealthLog"
trigger:
- platform: state
triggers:
- trigger: state
entity_id: sensor.bathroom_scale_weight
condition:
conditions:
- condition: template
value_template: "{{ trigger.to_state.state not in ['unknown', 'unavailable'] }}"
action:
- service: rest_command.healthlog_measurement
actions:
- action: rest_command.healthlog_measurement
data:
type: WEIGHT
value: "{{ trigger.to_state.state | float }}"
```
The `condition` is not decoration. Home Assistant sensors publish `unknown` and
The condition is not decoration. Home Assistant sensors publish `unknown` and
`unavailable` as ordinary states, and forwarding one produces a 422 on every
restart.
@@ -179,11 +179,17 @@ rest_command:
payload: "{{ readings | to_json }}"
```
Then import the blueprint, either from
Track one room, the one you sleep in: that is the one worth reading next to
your sleep. The readings carry no room name, so a second room would land in the
same series and blur both, and two pushes in the same minute collide on a 409
(see below).
Then import the blueprint (Home Assistant 2024.10 or newer), either from
[`docs/integrations/blueprints/healthlog-room-climate.yaml`](./blueprints/healthlog-room-climate.yaml)
or by its raw GitHub URL under Settings → Automations & scenes → Blueprints →
Import blueprint, and create one automation per room. It asks for the three
sensors (each optional) and an interval. Every push:
Import blueprint, and create one automation from it for that room. It asks for
the three sensors (each optional) and an interval of 5, 10, 15, 20, 30 or 60
minutes, counted from the full hour. Every push:
- leaves out a sensor that reports `unknown`, `unavailable` or anything that is
not a number, and skips the push when nothing is left;
@@ -197,8 +203,8 @@ single room without the blueprint:
```yaml
automation:
- alias: "Push bedroom climate to HealthLog"
trigger:
- platform: time_pattern
triggers:
- trigger: time_pattern
minutes: "/15"
variables:
readings: >-
@@ -215,19 +221,16 @@ automation:
{% endif %}
{% endfor %}
{{ ns.items }}
condition:
conditions:
- condition: template
value_template: "{{ readings | count > 0 }}"
action:
- service: rest_command.healthlog_room_climate
actions:
- action: rest_command.healthlog_room_climate
data:
readings: "{{ readings }}"
```
One request takes up to five readings, so a room is one call. Several rooms
are several automations: the readings carry no room name, and two rooms pushed
in the same minute collide on the 409 above. If you track more than one room,
pick the one you sleep in; that is the one worth reading next to your sleep.
One request takes up to five readings, so the room is one call.
## Sending several readings at once
@@ -0,0 +1,99 @@
/**
* The room-climate blueprint and its guide say one consistent thing.
*
* - The blueprint uses Home Assistant's current automation keys (`triggers`,
* `conditions`, `actions`, `trigger:` and `action:` inside them) and says
* which release it needs for them.
* - Every interval it offers divides the hour, so "every N minutes from the
* full hour" is what actually happens: the trigger fires every five minutes
* and the condition keeps `minute % N == 0`, which for an N that does not
* divide 60 drifts at the hour.
* - The guide recommends one room and never tells a reader to create one
* automation per room, which the readings (no room name) cannot support.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { parse, Scalar, type CollectionTag, type ScalarTag } from "yaml";
const ROOT = process.cwd();
const BLUEPRINT = join(
ROOT,
"docs/integrations/blueprints/healthlog-room-climate.yaml",
);
const GUIDE = join(ROOT, "docs/integrations/home-assistant.md");
const inputTag: ScalarTag = {
tag: "!input",
resolve: (value: string) => new Scalar(`!input ${value}`),
};
function blueprint(): Record<string, unknown> {
return parse(readFileSync(BLUEPRINT, "utf8"), {
customTags: [inputTag as ScalarTag | CollectionTag],
}) as Record<string, unknown>;
}
describe("room-climate blueprint", () => {
it("uses the current automation keys and names the release they need", () => {
const doc = blueprint();
for (const legacy of ["trigger", "condition", "action"]) {
expect(doc, legacy).not.toHaveProperty(legacy);
}
const triggers = doc.triggers as Array<Record<string, unknown>>;
const actions = doc.actions as Array<Record<string, unknown>>;
expect(triggers.every((t) => "trigger" in t && !("platform" in t))).toBe(
true,
);
expect(actions.every((a) => "action" in a && !("service" in a))).toBe(true);
const meta = doc.blueprint as {
homeassistant?: { min_version?: string };
};
expect(meta.homeassistant?.min_version).toMatch(/^2024\.10\./);
});
it("offers only intervals that divide the hour", () => {
const meta = blueprint().blueprint as {
input: {
interval_minutes: {
default: string;
selector: { select: { options: string[] } };
};
};
};
const options = meta.input.interval_minutes.selector.select.options;
expect(options.length).toBeGreaterThan(0);
for (const option of options) {
expect(60 % Number(option), option).toBe(0);
}
expect(options).toContain(meta.input.interval_minutes.default);
});
});
describe("room-climate guide", () => {
const guide = readFileSync(GUIDE, "utf8");
const section = guide.slice(
guide.indexOf("## Room climate"),
guide.indexOf("## Sending several readings at once"),
);
it("recommends one room and explains why", () => {
expect(section.length).toBeGreaterThan(200);
expect(section).toMatch(/Track one room/);
expect(section).toMatch(/409/);
expect(section).not.toMatch(/one automation per room/i);
expect(section).not.toMatch(/several automations/i);
});
it("writes its examples with the current keys", () => {
const examples = [...guide.matchAll(/```yaml\n([\s\S]*?)```/g)].map(
(m) => m[1]!,
);
expect(examples.length).toBeGreaterThan(0);
for (const example of examples) {
expect(example).not.toMatch(/^\s+- platform:/m);
expect(example).not.toMatch(/^\s+- service:/m);
}
});
});