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
+3
View File
@@ -917,6 +917,9 @@
"typeMindfulMinutes": "Achtsamkeitsminuten",
"typeStateOfMindValence": "Gemütszustand",
"typeAfibBurden": "VHF-Last",
"typeIndoorCo2": "CO₂ (innen)",
"typeIndoorTemperature": "Raumtemperatur",
"typeIndoorHumidity": "Luftfeuchtigkeit (innen)",
"durationReadAs": "Gelesen als {duration}",
"durationHoursMinutes": "{hours} Std. {minutes} Min.",
"durationHoursOnly": "{hours} Std.",
+3
View File
@@ -917,6 +917,9 @@
"typeMindfulMinutes": "Mindful minutes",
"typeStateOfMindValence": "State of mind",
"typeAfibBurden": "AFib burden",
"typeIndoorCo2": "Indoor CO₂",
"typeIndoorTemperature": "Indoor temperature",
"typeIndoorHumidity": "Indoor humidity",
"durationReadAs": "Read as {duration}",
"durationHoursMinutes": "{hours} h {minutes} min",
"durationHoursOnly": "{hours} h",
+3
View File
@@ -917,6 +917,9 @@
"typeMindfulMinutes": "Minutos de atención plena",
"typeStateOfMindValence": "Estado de ánimo",
"typeAfibBurden": "Carga de FA",
"typeIndoorCo2": "CO₂ interior",
"typeIndoorTemperature": "Temperatura interior",
"typeIndoorHumidity": "Humedad interior",
"durationReadAs": "Se lee como {duration}",
"durationHoursMinutes": "{hours} h {minutes} min",
"durationHoursOnly": "{hours} h",
+3
View File
@@ -917,6 +917,9 @@
"typeMindfulMinutes": "Minutes de pleine conscience",
"typeStateOfMindValence": "État d’esprit",
"typeAfibBurden": "Charge de FA",
"typeIndoorCo2": "CO₂ intérieur",
"typeIndoorTemperature": "Température intérieure",
"typeIndoorHumidity": "Humidité intérieure",
"durationReadAs": "Compris comme {duration}",
"durationHoursMinutes": "{hours} h {minutes} min",
"durationHoursOnly": "{hours} h",
+3
View File
@@ -917,6 +917,9 @@
"typeMindfulMinutes": "Minuti di consapevolezza",
"typeStateOfMindValence": "Stato d’animo",
"typeAfibBurden": "Carico di FA",
"typeIndoorCo2": "CO₂ interna",
"typeIndoorTemperature": "Temperatura interna",
"typeIndoorHumidity": "Umidità interna",
"durationReadAs": "Letto come {duration}",
"durationHoursMinutes": "{hours} h {minutes} min",
"durationHoursOnly": "{hours} h",
+3
View File
@@ -917,6 +917,9 @@
"typeMindfulMinutes": "마음 챙기기 시간(분)",
"typeStateOfMindValence": "마음 상태",
"typeAfibBurden": "심방세동 부담",
"typeIndoorCo2": "실내 CO₂",
"typeIndoorTemperature": "실내 온도",
"typeIndoorHumidity": "실내 습도",
"durationReadAs": "{duration}(으)로 읽었어요",
"durationHoursMinutes": "{hours}시간 {minutes}분",
"durationHoursOnly": "{hours}시간",
+3
View File
@@ -917,6 +917,9 @@
"typeMindfulMinutes": "Mindfulnessminuten",
"typeStateOfMindValence": "Gemoedstoestand",
"typeAfibBurden": "AF-belasting",
"typeIndoorCo2": "CO₂ binnen",
"typeIndoorTemperature": "Binnentemperatuur",
"typeIndoorHumidity": "Luchtvochtigheid binnen",
"durationReadAs": "Gelezen als {duration}",
"durationHoursMinutes": "{hours} u {minutes} min",
"durationHoursOnly": "{hours} u",
+3
View File
@@ -917,6 +917,9 @@
"typeMindfulMinutes": "Minuty uważności",
"typeStateOfMindValence": "Stan umysłu",
"typeAfibBurden": "Obciążenie AF",
"typeIndoorCo2": "CO₂ w pomieszczeniu",
"typeIndoorTemperature": "Temperatura w pomieszczeniu",
"typeIndoorHumidity": "Wilgotność w pomieszczeniu",
"durationReadAs": "Odczytano jako {duration}",
"durationHoursMinutes": "{hours} godz. {minutes} min",
"durationHoursOnly": "{hours} godz.",
@@ -0,0 +1,11 @@
-- Extend `measurement_type` with room-climate readings pushed through the
-- ingest token (source EXTERNAL), for example by a Home Assistant
-- automation: indoor CO2 (ppm), indoor temperature (celsius) and indoor
-- relative humidity (percent).
--
-- Purely additive. IF NOT EXISTS keeps a replay harmless; enum values cannot
-- be dropped in PostgreSQL, so there is no down path.
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'INDOOR_CO2';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'INDOOR_TEMPERATURE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'INDOOR_HUMIDITY';
+8
View File
@@ -1718,6 +1718,14 @@ enum MeasurementType {
STAND_HOURS // count — HealthKit `HKCategoryTypeIdentifierAppleStandHour`. One row per hour in which the person stood (`stood` = 1; an `idle` hour is not stored); cumulative, daily SUM gives the stand-ring hours.
MINDFUL_MINUTES // minutes — HealthKit `HKCategoryTypeIdentifierMindfulSession`. Session length from the sample's own dates; cumulative, daily SUM.
STATE_OF_MIND_VALENCE // valence (−1..1) — HealthKit `HKStateOfMind.valence` (iOS 18+). How pleasant the logged emotion or mood felt, from very unpleasant (−1) to very pleasant (1). A self-report, not a measurement.
// ── v1.44 — room climate from a home automation system ──
// Pushed through the ingest token (`measurements:write`, source EXTERNAL),
// e.g. by a Home Assistant automation. Spot readings: the daily rollup
// carries mean, min and max. Indoor, so never mixed with the outdoor
// environment channels.
INDOOR_CO2 // ppm — indoor carbon dioxide concentration.
INDOOR_TEMPERATURE // celsius — indoor air temperature. Distinct from every body temperature type.
INDOOR_HUMIDITY // percent (0–100) — indoor relative humidity.
AFIB_BURDEN // percent (0–100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `atrialFibrillationBurden` (iOS 16+, AFib History). The share of time the watch estimated in AFib over a week. AWARENESS ONLY — never a HealthLog diagnosis.
@@map("measurement_type")
@@ -74,8 +74,8 @@ describe("report scope gating — structural guard", () => {
it("reads a plausible catalogue", () => {
// Sanity floor: a silently degraded catalogue would pass everything below
// vacuously.
expect(ALL_LEAF_IDS.length).toBe(101);
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(83);
expect(ALL_LEAF_IDS.length).toBe(104);
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(86);
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(18);
});
@@ -139,6 +139,9 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
MINDFUL_MINUTES: "measurements.typeMindfulMinutes",
STATE_OF_MIND_VALENCE: "measurements.typeStateOfMindValence",
AFIB_BURDEN: "measurements.typeAfibBurden",
INDOOR_CO2: "measurements.typeIndoorCo2",
INDOOR_TEMPERATURE: "measurements.typeIndoorTemperature",
INDOOR_HUMIDITY: "measurements.typeIndoorHumidity",
};
export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
@@ -282,6 +285,10 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
MINDFUL_MINUTES: Wind,
STATE_OF_MIND_VALENCE: Gauge,
AFIB_BURDEN: HeartPulse,
// Room climate: air, warmth, moisture.
INDOOR_CO2: Wind,
INDOOR_TEMPERATURE: Thermometer,
INDOOR_HUMIDITY: Droplets,
};
// The colour map lives in its own icon-free module so a surface that only
@@ -138,4 +138,8 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
MINDFUL_MINUTES: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
STATE_OF_MIND_VALENCE: "bg-chart-1/10 text-chart-1 dark:bg-chart-1/20",
AFIB_BURDEN: "bg-chart-3/10 text-chart-3 dark:bg-chart-3/20",
// Room climate takes the environment family of daylight (chart-2).
INDOOR_CO2: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
INDOOR_TEMPERATURE: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
INDOOR_HUMIDITY: "bg-chart-2/10 text-chart-2 dark:bg-chart-2/20",
};
@@ -198,7 +198,7 @@ describe("<HealthRecordExportPanel> — the first run", () => {
expect(groupChip(html, "activity")).toBe("0/11");
expect(groupChip(html, "sleepRecovery")).toBe("0/16");
expect(groupChip(html, "mobility")).toBe("0/11");
expect(groupChip(html, "environment")).toBe("0/4");
expect(groupChip(html, "environment")).toBe("0/7");
});
it("disables Generate while the scope is empty, and says what to do", () => {
@@ -120,10 +120,14 @@ const EXPECTED_TYPES = [
"MINDFUL_MINUTES",
"STATE_OF_MIND_VALENCE",
"AFIB_BURDEN",
// ── v1.44 — room climate pushed through the ingest token ──
"INDOOR_CO2",
"INDOOR_TEMPERATURE",
"INDOOR_HUMIDITY",
] as const;
describe("measurementTypeEnum coverage", () => {
it("exposes the 83 canonical measurement types", () => {
it("exposes the 86 canonical measurement types", () => {
expect([...measurementTypeEnum.options].sort()).toEqual(
[...EXPECTED_TYPES].sort(),
);
+4
View File
@@ -145,6 +145,10 @@ export const ALLOWED_CHART_TOKENS = [
"metric:MINDFUL_MINUTES",
"metric:STATE_OF_MIND_VALENCE",
"metric:AFIB_BURDEN",
// v1.44 — room climate from the ingest token.
"metric:INDOOR_CO2",
"metric:INDOOR_TEMPERATURE",
"metric:INDOOR_HUMIDITY",
"metric:GRIP_STRENGTH",
"metric:PAIN_NRS",
"metric:WAIST_CIRCUMFERENCE",
@@ -23,6 +23,10 @@ import type { MeasurementType } from "@/generated/prisma/client";
* silently failing the assertion.
*/
const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set<MeasurementType>([
// v1.44 — room climate arrives through the ingest token, not HealthKit.
"INDOOR_CO2",
"INDOOR_TEMPERATURE",
"INDOOR_HUMIDITY",
// Withings-only metrics (no HK identifier ships any of these as a
// first-class quantity — Apple delegates body-composition tail to
// third-party devices via the BodyMass/BodyFat pair).
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { getUnitForType } from "@/lib/measurements/unit-map";
import { getDisplayTransform } from "@/lib/measurements/display-transform";
import { getMeasurementCategory } from "@/lib/measurements/categories";
import { createMeasurementSchema } from "@/lib/validations/measurement";
/**
* Room climate as a Home Assistant automation pushes it: the documented
* three-reading array through the ingest token, no source, no unit.
*/
const at = new Date(Date.now() - 60_000).toISOString();
describe("indoor climate types", () => {
it("accepts the documented push shape for all three", () => {
for (const [type, value] of [
["INDOOR_CO2", 812],
["INDOOR_TEMPERATURE", 21.4],
["INDOOR_HUMIDITY", 47],
] as const) {
expect(
createMeasurementSchema.safeParse({ type, value, measuredAt: at })
.success,
type,
).toBe(true);
}
});
it("refuses a reading no room produces", () => {
for (const [type, value] of [
["INDOOR_CO2", 12_000],
["INDOOR_TEMPERATURE", 75],
["INDOOR_HUMIDITY", 101],
] as const) {
expect(
createMeasurementSchema.safeParse({ type, value, measuredAt: at })
.success,
type,
).toBe(false);
}
});
it("stores canonical units and shows temperature in the person's scale", () => {
expect(getUnitForType("INDOOR_CO2")).toBe("ppm");
expect(getUnitForType("INDOOR_TEMPERATURE")).toBe("celsius");
expect(getUnitForType("INDOOR_HUMIDITY")).toBe("%");
expect(
getDisplayTransform("INDOOR_TEMPERATURE", "imperial").displayUnit,
).toBe("°F");
});
it("files all three under the environment cluster", () => {
for (const type of [
"INDOOR_CO2",
"INDOOR_TEMPERATURE",
"INDOOR_HUMIDITY",
] as const) {
expect(getMeasurementCategory(type)).toBe("environment");
}
});
});
@@ -67,6 +67,7 @@ const SHARED_SYMBOLS: ReadonlySet<string> = new Set([
"°C",
"°F",
"dBA",
"ppm",
"mL/(kg·min)",
"kg/m²",
"mL",
+4
View File
@@ -230,6 +230,10 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap<
["STATE_OF_MIND_VALENCE", "scores"],
// AFib burden joins the rhythm notifications on the cardiovascular surface.
["AFIB_BURDEN", "cardiovascular"],
// ── v1.44 — room climate joins daylight in the environment cluster ──
["INDOOR_CO2", "environment"],
["INDOOR_TEMPERATURE", "environment"],
["INDOOR_HUMIDITY", "environment"],
]);
/**
@@ -179,6 +179,8 @@ const TRANSFORMS: Partial<
BODY_TEMPERATURE: TEMPERATURE_TRANSFORM,
SKIN_TEMPERATURE: TEMPERATURE_TRANSFORM,
WRIST_TEMPERATURE: TEMPERATURE_TRANSFORM,
// v1.44 — room temperature reads in the person's own scale too.
INDOOR_TEMPERATURE: TEMPERATURE_TRANSFORM,
// A signed baseline DEVIATION (Δ°C) — the °F imperial branch scales by
// 1.8 but carries NO +32 offset. A one-degree-Celsius deviation is a
// 1.8-degree-Fahrenheit deviation, not a 33.8 °F reading.
@@ -338,6 +340,8 @@ const INTEGER_DISPLAY_TYPES: ReadonlySet<string> = new Set([
"STAND_MINUTES",
"STAND_HOURS",
"MINDFUL_MINUTES",
// CO2 sensors report whole ppm.
"INDOOR_CO2",
]);
/**
+3
View File
@@ -110,6 +110,9 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<MeasurementType, string> = {
MINDFUL_MINUTES: "measurements.typeMindfulMinutes",
STATE_OF_MIND_VALENCE: "measurements.typeStateOfMindValence",
AFIB_BURDEN: "measurements.typeAfibBurden",
INDOOR_CO2: "measurements.typeIndoorCo2",
INDOOR_TEMPERATURE: "measurements.typeIndoorTemperature",
INDOOR_HUMIDITY: "measurements.typeIndoorHumidity",
GRIP_STRENGTH: "measurements.typeGripStrength",
PAIN_NRS: "measurements.typePainNrs",
WAIST_CIRCUMFERENCE: "measurements.typeWaistCircumference",
+4
View File
@@ -166,6 +166,10 @@ const unitMap: Record<string, string> = {
STATE_OF_MIND_VALENCE: "valence",
// Percent of the week after the ×100 scaling.
AFIB_BURDEN: "%",
// ── v1.44 — room climate ──
INDOOR_CO2: "ppm",
INDOOR_TEMPERATURE: "celsius",
INDOOR_HUMIDITY: "%",
};
export function getUnitForType(type: string): string {
+4
View File
@@ -217,6 +217,10 @@ export function getPRDirection(
// Neither is a goal axis a record could celebrate.
case "AFIB_BURDEN":
case "STATE_OF_MIND_VALENCE":
// v1.44 — room climate describes a room, not a person's effort.
case "INDOOR_CO2":
case "INDOOR_TEMPERATURE":
case "INDOOR_HUMIDITY":
case "HIGH_HEART_RATE_EVENT":
case "LOW_HEART_RATE_EVENT":
case "WALKING_STEADINESS_EVENT":
@@ -53,9 +53,9 @@ function resolves(key: string): boolean {
describe("report selection catalogue", () => {
it("carries every measurement type and every structured leaf", () => {
expect(MEASUREMENT_LEAF_IDS).toHaveLength(83);
expect(MEASUREMENT_LEAF_IDS).toHaveLength(86);
expect(STRUCTURED_LEAF_IDS).toHaveLength(18);
expect(ALL_LEAF_IDS).toHaveLength(101);
expect(ALL_LEAF_IDS).toHaveLength(104);
});
it("resolves a label for every leaf", () => {
+6
View File
@@ -184,6 +184,9 @@ export const MEASUREMENT_LEAF_GROUP: Record<MeasurementType, ReportGroupId> = {
AUDIO_EXPOSURE_HEADPHONE: "environment",
AUDIO_EXPOSURE_EVENT: "environment",
TIME_IN_DAYLIGHT: "environment",
INDOOR_CO2: "environment",
INDOOR_TEMPERATURE: "environment",
INDOOR_HUMIDITY: "environment",
// ── glucose ──
BLOOD_GLUCOSE: "glucose",
@@ -311,6 +314,9 @@ export const LEAF_MODULE: Partial<Record<ReportLeafId, ModuleKey>> = {
AUDIO_EXPOSURE_HEADPHONE: "environment",
AUDIO_EXPOSURE_EVENT: "environment",
TIME_IN_DAYLIGHT: "environment",
INDOOR_CO2: "environment",
INDOOR_TEMPERATURE: "environment",
INDOOR_HUMIDITY: "environment",
// Glucose: the raw series and the derived panel.
BLOOD_GLUCOSE: "glucose",
GLUCOSE_PANEL: "glucose",
@@ -298,5 +298,23 @@
"display": "Atrial fibrillation burden",
"unit": "%",
"category": "vital-signs"
},
"INDOOR_CO2": {
"loinc": null,
"display": "Indoor carbon dioxide",
"unit": "[ppm]",
"category": "activity"
},
"INDOOR_TEMPERATURE": {
"loinc": null,
"display": "Indoor air temperature",
"unit": "Cel",
"category": "activity"
},
"INDOOR_HUMIDITY": {
"loinc": null,
"display": "Indoor relative humidity",
"unit": "%",
"category": "activity"
}
}
+72
View File
@@ -1914,6 +1914,78 @@ export const SIGNALS: Record<string, SignalDefinition> = {
category: "vital-signs",
},
},
// ── v1.44 — room climate pushed through the ingest token ──
// Bands are the usual comfort guidance (CO2 under 1000 ppm as the long-
// standing ventilation threshold, 18–24 °C, 40–60 % RH); placement only.
INDOOR_CO2: {
key: "INDOOR_CO2",
kind: "measurement",
source: { measurementType: "INDOOR_CO2" },
displayName: "Indoor CO2",
unit: "ppm",
direction: "target-band",
archetype: "environmental-exposure",
normalRange: { low: 400, high: 1000 },
surfaces: {
detailPage: false,
correlationEligible: false,
coachSnapshot: false,
mcp: false,
},
fhir: {
// A room reading, not a body observation; no LOINC concept applies.
loinc: null,
display: "Indoor carbon dioxide",
unit: "[ppm]",
category: "activity",
},
},
INDOOR_TEMPERATURE: {
key: "INDOOR_TEMPERATURE",
kind: "measurement",
source: { measurementType: "INDOOR_TEMPERATURE" },
displayName: "Indoor temperature",
unit: "°C",
direction: "target-band",
archetype: "environmental-exposure",
normalRange: { low: 18, high: 24 },
surfaces: {
detailPage: false,
correlationEligible: false,
coachSnapshot: false,
mcp: false,
},
fhir: {
// A room reading, not a body observation; no LOINC concept applies.
loinc: null,
display: "Indoor air temperature",
unit: "Cel",
category: "activity",
},
},
INDOOR_HUMIDITY: {
key: "INDOOR_HUMIDITY",
kind: "measurement",
source: { measurementType: "INDOOR_HUMIDITY" },
displayName: "Indoor humidity",
unit: "%",
direction: "target-band",
archetype: "environmental-exposure",
normalRange: { low: 40, high: 60 },
surfaces: {
detailPage: false,
correlationEligible: false,
coachSnapshot: false,
mcp: false,
},
fhir: {
// A room reading, not a body observation; no LOINC concept applies.
loinc: null,
display: "Indoor relative humidity",
unit: "%",
category: "activity",
},
},
};
/** Type guard: narrow an arbitrary string to a registered signal key. */
+11
View File
@@ -153,6 +153,10 @@ export const measurementTypeEnum = z.enum([
"MINDFUL_MINUTES",
"STATE_OF_MIND_VALENCE",
"AFIB_BURDEN",
// ── v1.44 — room climate pushed through the ingest token ──
"INDOOR_CO2",
"INDOOR_TEMPERATURE",
"INDOOR_HUMIDITY",
]);
/**
@@ -460,6 +464,13 @@ export const VALUE_RANGES: Record<string, { min: number; max: number }> = {
// AFib burden as a percent of the week, after the ×100 scaling. A client
// that already scaled sends 2 for 2 %, which lands at 200 and is refused.
AFIB_BURDEN: { min: 0, max: 100 },
// ── v1.44 — room climate. CO2: outdoor air sits near 420 ppm and a closed
// bedroom can pass 3000; consumer sensors top out at 5000–10000 ppm.
INDOOR_CO2: { min: 0, max: 10000 },
// Indoor air °C: a cold cellar to an overheated attic.
INDOOR_TEMPERATURE: { min: -20, max: 60 },
// Relative humidity, percent.
INDOOR_HUMIDITY: { min: 0, max: 100 },
// Grip strength kg — a child can read in the single digits, an elite athlete
// tops ~90 kg; 120 is a generous ceiling that still rejects sensor noise.
GRIP_STRENGTH: { min: 0, max: 120 },