mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
b76826a5f8
commit
cc8ea3dfbb
32 files changed
+463
-9
No files matched your search
+30
-3
@@ -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 }}"
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -917,6 +917,9 @@
|
||||
"typeMindfulMinutes": "마음 챙기기 시간(분)",
|
||||
"typeStateOfMindValence": "마음 상태",
|
||||
"typeAfibBurden": "심방세동 부담",
|
||||
"typeIndoorCo2": "실내 CO₂",
|
||||
"typeIndoorTemperature": "실내 온도",
|
||||
"typeIndoorHumidity": "실내 습도",
|
||||
"durationReadAs": "{duration}(으)로 읽었어요",
|
||||
"durationHoursMinutes": "{hours}시간 {minutes}분",
|
||||
"durationHoursOnly": "{hours}시간",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user