feat(measurements): accept indoor CO2, temperature and humidity

Room climate from a home automation system gets three types of its own:
INDOOR_CO2 (ppm), INDOOR_TEMPERATURE (celsius, shown in Fahrenheit for
imperial readers) and INDOOR_HUMIDITY (percent). They arrive through the
existing ingest token (measurements:write, source EXTERNAL), sit in the
environment cluster beside daylight, and the daily rollup carries their
mean, low and high like any spot reading. They never mix with the body
temperatures or with the outdoor environment channels.

The Home Assistant page documents the three types, a rest_command that
sends a room as one request, a single-room automation, and an
importable blueprint (docs/integrations/blueprints) that skips sensors
reporting unknown or non-numeric states, converts Fahrenheit, and
stamps readings to the minute so a retried push is refused rather than
written twice.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 20:26:15 +02:00
1 parent b76826a5f8
commit cc8ea3dfbb
32 files changed
+463 -9

No files matched your search

+30 -3
View File
@@ -4321,6 +4321,9 @@ paths:
- MINDFUL_MINUTES
- STATE_OF_MIND_VALENCE
- AFIB_BURDEN
- INDOOR_CO2
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
- in: query
name: from
schema:
@@ -26000,7 +26003,7 @@ components:
type: number
const: 2
leaves:
maxItems: 101
maxItems: 104
type: array
items:
type: string
@@ -26060,7 +26063,7 @@ components:
type: number
const: 2
leaves:
maxItems: 101
maxItems: 104
type: array
items:
type: string
@@ -26901,6 +26904,9 @@ components:
- MINDFUL_MINUTES
- STATE_OF_MIND_VALENCE
- AFIB_BURDEN
- INDOOR_CO2
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
value:
type: number
measuredAt:
@@ -28588,6 +28594,9 @@ components:
- MINDFUL_MINUTES
- STATE_OF_MIND_VALENCE
- AFIB_BURDEN
- INDOOR_CO2
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
biomarkerId:
type: string
minLength: 1
@@ -29087,7 +29096,7 @@ components:
type: number
const: 2
leaves:
maxItems: 101
maxItems: 104
type: array
items:
type: string
@@ -30264,6 +30273,9 @@ components:
- MINDFUL_MINUTES
- STATE_OF_MIND_VALENCE
- AFIB_BURDEN
- INDOOR_CO2
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
description: DB-stored measurement category. v1.4.23 added 7 Apple Health values (HRV, resting HR, active energy,
flights, walking/running distance, VO2 max, body temperature).
InsightsSettingsPutRequest:
@@ -39046,6 +39058,9 @@ components:
- STAND_HOURS
- MINDFUL_MINUTES
- STATE_OF_MIND_VALENCE
- INDOOR_CO2
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
- AFIB_BURDEN
value:
type: number
@@ -39792,6 +39807,9 @@ components:
- STAND_HOURS
- MINDFUL_MINUTES
- STATE_OF_MIND_VALENCE
- INDOOR_CO2
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
- AFIB_BURDEN
- type: "null"
biomarkerName:
@@ -42273,6 +42291,9 @@ components:
- STAND_HOURS
- MINDFUL_MINUTES
- STATE_OF_MIND_VALENCE
- INDOOR_CO2
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
- AFIB_BURDEN
dateKey:
type: string
@@ -43457,6 +43478,9 @@ components:
- STAND_HOURS
- MINDFUL_MINUTES
- STATE_OF_MIND_VALENCE
- INDOOR_CO2
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
- AFIB_BURDEN
- type: "null"
intervalDays:
@@ -43675,6 +43699,9 @@ components:
- STAND_HOURS
- MINDFUL_MINUTES
- STATE_OF_MIND_VALENCE
- INDOOR_CO2
- INDOOR_TEMPERATURE
- INDOOR_HUMIDITY
- AFIB_BURDEN
metricSlot:
type:
@@ -0,0 +1,93 @@
# Push a room's CO2, temperature and humidity to HealthLog every few minutes.
#
# 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`,
# `unavailable` or a non-number is left out of that push, and a push with
# nothing left is skipped. Temperatures in °F are converted to °C.
blueprint:
name: HealthLog room climate
description: >-
Sends indoor CO2 (ppm), temperature (°C) and relative humidity (%) to
HealthLog through a measurement ingest token. HealthLog keeps each
reading and shows the day's mean, low and high.
domain: automation
source_url: https://github.com/MBombeck/HealthLog/blob/main/docs/integrations/blueprints/healthlog-room-climate.yaml
input:
co2_sensor:
name: CO2 sensor
description: A sensor reporting carbon dioxide in ppm.
default: ""
selector:
entity:
domain: sensor
device_class: carbon_dioxide
temperature_sensor:
name: Temperature sensor
description: A sensor reporting the room temperature (°C or °F).
default: ""
selector:
entity:
domain: sensor
device_class: temperature
humidity_sensor:
name: Humidity sensor
description: A sensor reporting relative humidity in %.
default: ""
selector:
entity:
domain: sensor
device_class: humidity
interval_minutes:
name: Interval
description: Minutes between pushes. 15 is plenty for a daily picture.
default: 15
selector:
number:
min: 5
max: 60
step: 5
unit_of_measurement: min
mode: single
variables:
co2_sensor: !input co2_sensor
temperature_sensor: !input temperature_sensor
humidity_sensor: !input humidity_sensor
interval_minutes: !input interval_minutes
# A list of readings (Home Assistant keeps a rendered list a list).
readings: >-
{% set at = now().replace(second=0, microsecond=0).isoformat() %}
{% set ns = namespace(items=[]) %}
{% for type, entity in [
('INDOOR_CO2', co2_sensor),
('INDOOR_TEMPERATURE', temperature_sensor),
('INDOOR_HUMIDITY', humidity_sensor)
] %}
{% if entity and is_number(states(entity)) %}
{% set v = states(entity) | float %}
{% if type == 'INDOOR_TEMPERATURE'
and state_attr(entity, 'unit_of_measurement') == '°F' %}
{% set v = ((v - 32) * 5 / 9) | round(2) %}
{% endif %}
{% set ns.items = ns.items
+ [{'type': type, 'value': v, 'measuredAt': at}] %}
{% endif %}
{% endfor %}
{{ ns.items }}
trigger:
- platform: time_pattern
minutes: "/5"
condition:
- condition: template
value_template: >-
{{ now().minute % (interval_minutes | int(15)) == 0
and readings | count > 0 }}
action:
- service: rest_command.healthlog_room_climate
data:
readings: "{{ readings }}"
+80
View File
@@ -149,6 +149,86 @@ Units are derived server-side from the type, so send the number in the unit
above and do not send a `unit` field. The full enum is in
`docs/api/openapi.yaml` under `MeasurementType`.
## Room climate
Indoor CO₂, temperature and humidity are their own types, kept apart from the
body temperatures and from the outdoor weather the environment module pulls:
| What the sensor reports | `type` | Unit expected |
| ----------------------- | -------------------- | ------------- |
| Carbon dioxide | `INDOOR_CO2` | ppm |
| Room temperature | `INDOOR_TEMPERATURE` | °C |
| Relative humidity | `INDOOR_HUMIDITY` | % |
Each push is kept as a reading, and the day's mean, low and high are what the
charts and the daily views read, so a sensor sampled every 15 minutes gives a
clear picture without anything further on your side. Pushing every few seconds
adds rows, not detail.
Add a second `rest_command` that takes a ready-made list of readings, so one
request carries all three:
```yaml
rest_command:
healthlog_room_climate:
url: "https://healthlog.example.com/api/measurements"
method: POST
headers:
Authorization: !secret healthlog_token
Content-Type: "application/json"
payload: "{{ readings | to_json }}"
```
Then import the blueprint, 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:
- leaves out a sensor that reports `unknown`, `unavailable` or anything that is
not a number, and skips the push when nothing is left;
- converts a temperature reported in °F to °C;
- stamps all readings with the current minute, so a retry of the same push is
refused with a 409 instead of written twice.
If you would rather write the automation yourself, this is the same thing for a
single room without the blueprint:
```yaml
automation:
- alias: "Push bedroom climate to HealthLog"
trigger:
- platform: time_pattern
minutes: "/15"
variables:
readings: >-
{% set at = now().replace(second=0, microsecond=0).isoformat() %}
{% set ns = namespace(items=[]) %}
{% for type, entity in [
('INDOOR_CO2', 'sensor.bedroom_co2'),
('INDOOR_TEMPERATURE', 'sensor.bedroom_temperature'),
('INDOOR_HUMIDITY', 'sensor.bedroom_humidity')
] %}
{% if is_number(states(entity)) %}
{% set ns.items = ns.items + [{'type': type,
'value': states(entity) | float, 'measuredAt': at}] %}
{% endif %}
{% endfor %}
{{ ns.items }}
condition:
- condition: template
value_template: "{{ readings | count > 0 }}"
action:
- service: 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.
## Sending several readings at once
A bare array writes up to five readings in one request — the shape to use for