diff --git a/docs/integrations/blueprints/healthlog-room-climate.yaml b/docs/integrations/blueprints/healthlog-room-climate.yaml index 205ad0708..c573eb036 100644 --- a/docs/integrations/blueprints/healthlog-room-climate.yaml +++ b/docs/integrations/blueprints/healthlog-room-climate.yaml @@ -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 }}" diff --git a/docs/integrations/home-assistant.md b/docs/integrations/home-assistant.md index d5423e650..d858e7e80 100644 --- a/docs/integrations/home-assistant.md +++ b/docs/integrations/home-assistant.md @@ -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 diff --git a/src/__tests__/home-assistant-room-climate-docs.test.ts b/src/__tests__/home-assistant-room-climate-docs.test.ts new file mode 100644 index 000000000..58d583504 --- /dev/null +++ b/src/__tests__/home-assistant-room-climate-docs.test.ts @@ -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 { + return parse(readFileSync(BLUEPRINT, "utf8"), { + customTags: [inputTag as ScalarTag | CollectionTag], + }) as Record; +} + +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>; + const actions = doc.actions as Array>; + 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); + } + }); +});