diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b390c93b3..35e6be480 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/docs/integrations/blueprints/healthlog-room-climate.yaml b/docs/integrations/blueprints/healthlog-room-climate.yaml new file mode 100644 index 000000000..205ad0708 --- /dev/null +++ b/docs/integrations/blueprints/healthlog-room-climate.yaml @@ -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 }}" diff --git a/docs/integrations/home-assistant.md b/docs/integrations/home-assistant.md index 71d679ccc..d5423e650 100644 --- a/docs/integrations/home-assistant.md +++ b/docs/integrations/home-assistant.md @@ -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 diff --git a/messages/de.json b/messages/de.json index 6b34c7498..9e4111523 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index 60fe5c6be..6b5b799b4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index fcfada1b1..f107f380c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 843d5ebf6..2226dbcc2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 15d737a1c..570087490 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index f23096369..92402257f 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -917,6 +917,9 @@ "typeMindfulMinutes": "마음 챙기기 시간(분)", "typeStateOfMindValence": "마음 상태", "typeAfibBurden": "심방세동 부담", + "typeIndoorCo2": "실내 CO₂", + "typeIndoorTemperature": "실내 온도", + "typeIndoorHumidity": "실내 습도", "durationReadAs": "{duration}(으)로 읽었어요", "durationHoursMinutes": "{hours}시간 {minutes}분", "durationHoursOnly": "{hours}시간", diff --git a/messages/nl.json b/messages/nl.json index 3f6fabe35..af65f651a 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 66982c68e..3f0e4089d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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.", diff --git a/prisma/migrations/0433_indoor_climate_types/migration.sql b/prisma/migrations/0433_indoor_climate_types/migration.sql new file mode 100644 index 000000000..c07e7c564 --- /dev/null +++ b/prisma/migrations/0433_indoor_climate_types/migration.sql @@ -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'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 82129aac4..d6eeead54 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/src/__tests__/doctor-report-control-gating-guard.test.ts b/src/__tests__/doctor-report-control-gating-guard.test.ts index 1c06583ae..b0763a13f 100644 --- a/src/__tests__/doctor-report-control-gating-guard.test.ts +++ b/src/__tests__/doctor-report-control-gating-guard.test.ts @@ -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); }); diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index 5be62c111..e0d6a8125 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -139,6 +139,9 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { 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 = { @@ -282,6 +285,10 @@ export const MEASUREMENT_TYPE_ICONS: Record = { 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 diff --git a/src/components/measurements/measurement-type-colors.ts b/src/components/measurements/measurement-type-colors.ts index 76a7d6803..98409b66f 100644 --- a/src/components/measurements/measurement-type-colors.ts +++ b/src/components/measurements/measurement-type-colors.ts @@ -138,4 +138,8 @@ export const MEASUREMENT_TYPE_COLORS: Record = { 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", }; diff --git a/src/components/settings/__tests__/report-selection-panel.test.tsx b/src/components/settings/__tests__/report-selection-panel.test.tsx index b3c162515..ca32f7ac3 100644 --- a/src/components/settings/__tests__/report-selection-panel.test.tsx +++ b/src/components/settings/__tests__/report-selection-panel.test.tsx @@ -198,7 +198,7 @@ describe(" — 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", () => { diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index 5ef5738b7..79c4093c6 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -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(), ); diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index 94df12acc..3b0fef932 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -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", diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index 86afc5d32..b1ffdc978 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -23,6 +23,10 @@ import type { MeasurementType } from "@/generated/prisma/client"; * silently failing the assertion. */ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set([ + // 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). diff --git a/src/lib/measurements/__tests__/indoor-climate.test.ts b/src/lib/measurements/__tests__/indoor-climate.test.ts new file mode 100644 index 000000000..a7ebc3d69 --- /dev/null +++ b/src/lib/measurements/__tests__/indoor-climate.test.ts @@ -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"); + } + }); +}); diff --git a/src/lib/measurements/__tests__/unit-label.test.ts b/src/lib/measurements/__tests__/unit-label.test.ts index 3c07ae748..dfd947eba 100644 --- a/src/lib/measurements/__tests__/unit-label.test.ts +++ b/src/lib/measurements/__tests__/unit-label.test.ts @@ -67,6 +67,7 @@ const SHARED_SYMBOLS: ReadonlySet = new Set([ "°C", "°F", "dBA", + "ppm", "mL/(kg·min)", "kg/m²", "mL", diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index 0bff7c895..0620110b2 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -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"], ]); /** diff --git a/src/lib/measurements/display-transform.ts b/src/lib/measurements/display-transform.ts index 03a4382cd..2dc71e322 100644 --- a/src/lib/measurements/display-transform.ts +++ b/src/lib/measurements/display-transform.ts @@ -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 = new Set([ "STAND_MINUTES", "STAND_HOURS", "MINDFUL_MINUTES", + // CO2 sensors report whole ppm. + "INDOOR_CO2", ]); /** diff --git a/src/lib/measurements/type-label-keys.ts b/src/lib/measurements/type-label-keys.ts index 46a61d0a4..12e3cf186 100644 --- a/src/lib/measurements/type-label-keys.ts +++ b/src/lib/measurements/type-label-keys.ts @@ -110,6 +110,9 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { 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", diff --git a/src/lib/measurements/unit-map.ts b/src/lib/measurements/unit-map.ts index 71761e35c..943a0fade 100644 --- a/src/lib/measurements/unit-map.ts +++ b/src/lib/measurements/unit-map.ts @@ -166,6 +166,10 @@ const unitMap: Record = { 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 { diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index dc8ecf4ad..f2ff28f9a 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -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": diff --git a/src/lib/report-selection/__tests__/catalogue-guard.test.ts b/src/lib/report-selection/__tests__/catalogue-guard.test.ts index 4e3ba4439..593bec93c 100644 --- a/src/lib/report-selection/__tests__/catalogue-guard.test.ts +++ b/src/lib/report-selection/__tests__/catalogue-guard.test.ts @@ -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", () => { diff --git a/src/lib/report-selection/catalogue.ts b/src/lib/report-selection/catalogue.ts index f53f1287a..de1683fef 100644 --- a/src/lib/report-selection/catalogue.ts +++ b/src/lib/report-selection/catalogue.ts @@ -184,6 +184,9 @@ export const MEASUREMENT_LEAF_GROUP: Record = { 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> = { 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", diff --git a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json index 905fa945d..c55d1200c 100644 --- a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json +++ b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json @@ -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" } } diff --git a/src/lib/signals/registry.ts b/src/lib/signals/registry.ts index d889362a8..cf467c8be 100644 --- a/src/lib/signals/registry.ts +++ b/src/lib/signals/registry.ts @@ -1914,6 +1914,78 @@ export const SIGNALS: Record = { 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. */ diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 7134461ac..fa6a5692b 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -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 = { // 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 },