Home Assistant Sleep Tracker - A Custom Home Assistant Integration for Sleep Tracking.
  • Python 85%
  • JavaScript 13.1%
  • HTML 1.8%
  • Shell 0.1%
Find a file
2026-11-05 19:12:05 -05:00
.forgejo/workflows Prepare the 0.1.0 release, with release notes from the changelog 2026-11-05 14:31:08 -05:00
.vscode Initial commit: Sleep Tracker integration and sleep report card for Home Assistant 2026-11-04 17:53:48 -05:00
assets/brand Initial commit: Sleep Tracker integration and sleep report card for Home Assistant 2026-11-04 17:53:48 -05:00
custom_components/sleep_tracker Option to hide the cards disclaimers; drop "(estimate)" from card titles 2026-11-05 19:12:05 -05:00
docs Show the card screenshots without frames or a table in the README 2026-11-05 14:40:55 -05:00
scripts Show the card screenshots without frames or a table in the README 2026-11-05 14:40:55 -05:00
tests Option to hide the cards disclaimers; drop "(estimate)" from card titles 2026-11-05 19:12:05 -05:00
tools Let recompute rebuild a night from the recorder so changed settings apply 2026-11-05 10:47:20 -05:00
.gitignore Initial commit: Sleep Tracker integration and sleep report card for Home Assistant 2026-11-04 17:53:48 -05:00
CHANGELOG.md Option to hide the cards disclaimers; drop "(estimate)" from card titles 2026-11-05 19:12:05 -05:00
hacs.json Prepare the 0.1.0 release, with release notes from the changelog 2026-11-05 14:31:08 -05:00
LICENSE Initial commit: Sleep Tracker integration and sleep report card for Home Assistant 2026-11-04 17:53:48 -05:00
pyproject.toml Initial commit: Sleep Tracker integration and sleep report card for Home Assistant 2026-11-04 17:53:48 -05:00
README.md Option to hide the cards disclaimers; drop "(estimate)" from card titles 2026-11-05 19:12:05 -05:00
requirements_test.txt Initial commit: Sleep Tracker integration and sleep report card for Home Assistant 2026-11-04 17:53:48 -05:00

Sleep Tracker for Home Assistant

Turns bedroom sensors you already have (a bed pressure sensor, a wearable oximeter, temperature and humidity sensors, optionally a radar or the Companion app) into sleep tracking. Everything runs locally inside Home Assistant: no vendor cloud and no phone app required.

Not a medical device. All figures are estimates for personal insight. They must not be used for diagnosis or treatment.

Features

  • Works with the sensors you have. You map existing entities (bed pressure, presence, a wearable's heart rate, SpO2 and motion, temperatures, lights, the phone screen) and the features they allow turn on. Only a bed pressure or presence sensor is required.
  • Live state for automations: in bed, asleep or awake, and the bed's thermal state, with events when you fall asleep and wake up.
  • A sleep score from 0 to 100 for each night, with its parts shown: duration, efficiency, continuity, falling asleep, regularity and vitals.
  • A nightly summary for each sleeper:
    • bedtime and wake time
    • total sleep, sleep efficiency and time to fall asleep
    • time awake in the night, awakenings and times out of bed
    • night trips (confirmed by bathroom or hall motion, a door or a light, if you map them)
    • turns and restlessness
  • Vitals from a wearable oximeter: sleeping heart rate, SpO2 drops of 4 % or more, drops per hour, and time below 90 %.
  • Sleep stages (experimental): light, deep and REM sleep, estimated from heart rate and movement, for each night and live for automations.
  • Apnea screening (opt-in, experimental): an estimated breathing-event index from the oximeter, with a screening range and a clear disclaimer.
  • Alerts: low SpO2, low or high heart rate while asleep, a wearable or bed sensor that stops reporting, and low batteries at bedtime, as events and optionally on your phone, with each sleeper's own thresholds. With a breathing-rate sensor such as a radar, also an experimental breathing-stopped alert.
  • Smart wake: a nudge in the window before your wake-up time, at the first sign of lighter sleep, and an earlier pre-wake to start a slow sunrise, so automations can wake you gently.
  • Bed climate: thermal comfort, time too warm or too cold, and sweat events, plus how your nights compare at different room and bed temperatures.
  • A sleep report card for dashboards: the night's score, timeline and figures against common targets, and trends over 7 or 30 nights.
  • A shared bed: track one or both sides. Your partner's movement, turns and getting in or out of bed don't count against your night, including when they aren't tracked themselves. Calibrating the bed sensor makes this more precise.
  • Built to keep working:
    • bed sensor calibration
    • nights that survive Home Assistant restarts
    • backfill from a device's stored recording, automatic for the PO6 with viatom_oximeter
    • rebuilding a stored night from the recorder after changing settings
    • a replay tool for tuning on real nights
  • In English and French, from the setup screens to alerts and the card.

Entities

Each tracked sleeper becomes a device with these entities. Entities are only created when the sensors they need are mapped.

Entity Needs
In bed, Asleep, Sleep state bed pressure or presence (+ a movement source for asleep)
Sleep stage (live, experimental) heart rate
Sleep score bed pressure or presence
Total sleep, Time in bed, Time to fall asleep, Awake after falling asleep, Sleep efficiency, Awakenings, Times out of bed, Night trips, Bedtime, Wake time bed pressure or presence
Restlessness bed pressure or wearable motion
Turns bed pressure
Lowest / average sleeping heart rate heart rate
Light sleep, Deep sleep, REM sleep (experimental) heart rate
Average / lowest SpO2, SpO2 drops, SpO2 drops per hour, Time with SpO2 below 90 % SpO2
Thermal state, Thermal comfort, Time too warm / too cold bed or room temperature
Bed / bedroom temperature and humidity the matching sensor
Sweat events bed humidity
Estimated breathing-event index, Screening range (estimate), Hypoxic burden (opt-in) SpO2, with apnea screening turned on
Smart wake (switch), Smart wake window, Pre-wake lead bed pressure or presence
Wake-up time bed pressure or presence, with smart wake aiming for a wake-up time
Alerts (switch, on by default) bed pressure or presence

Nightly sensors show the most recent finished night (attribute night). A night finishes 30 minutes after you get out of bed, so a trip to the bathroom doesn't split it in two. The report and these sensors update then. Change the wait under Configure → Settings (Minutes out of bed before a session ends).

Falling asleep takes 15 minutes of lying still, or 10 once the lights or phone screen have been off for 10 minutes, and a little longer if your heart rate hasn't dropped yet. With a heart-rate sensor mapped, it also needs a pulse from it that night or the lights off, and the quiet time counts from whichever comes first: lying still with the lights on before you put the ring on is likely reading. The night's report follows the same rule. So on a night you forget the ring, turning the lights off is enough. Map your bedroom lights under Configure → Sensors for this; without them, the live state stays awake on those nights, but the report still works out your sleep from movement.

SpO2 drops are dips of at least 4 points below your recent baseline that last at least 10 seconds: the 4 % oxygen desaturation index (ODI) that home sleep tests report.

  • The baseline is the median SpO2 of the previous 2 minutes, leaving out earlier drops.
  • Drops are counted from falling asleep to the final wake, while in bed. They include brief wakes, because a drop often ends in one.
  • Readings while the wearable isn't worn are skipped.
  • Drops per hour is the count divided by the hours of valid SpO2. It's only reported once there's at least an hour of it.
  • Time below 90 % is the time with SpO2 under 90 % over the same period.

The detector works on a 5-second SpO2 trace, kept from live readings or from a recording imported with import_samples. Like everything here, these figures are estimates, not a diagnosis.

The sleep score adds up six parts, each scored 0–100 and weighted:

Part Weight Full marks → zero
Duration 30 Your sleep target (8 h by default, under Configure → Settings) → none. Sleeping more than 90 min over the target costs a little.
Efficiency 20 95 % of your time in bed asleep → 70 %
Continuity 15 No time awake in the night → an hour, minus a little for each awakening beyond three
Falling asleep 10 20 min or less → an hour
Regularity 10 Bedtime within 15 min of your usual → 90 min off. Needs three earlier nights.
Vitals 15 SpO2 drops per hour, time below 90 % and how much your heart rate drops when you fall asleep, whichever you have

A part with no data is left out, and its weight is shared by the others. The sensor's parts attribute shows each part, so you can see why a night scored what it did. Room temperature and other causes are reported, not scored, so you can check whether they affect your sleep.

Night trips are times out of bed between falling asleep and your final wake, of at least a minute. If you map Night trip sensors (bathroom or hall motion, a bedroom door, a bathroom light) under Configure → Sensors, only the absences they confirm count, within a minute either side.

Sleep stages are an experimental estimate, from heart rate and movement. Each asleep moment is judged over the 5 minutes around it.

  • The two heart-rate features: the level, and how much it jumps between consecutive 30-second readings. Both are ranked within the night, so each night is compared with itself.
  • Deep sleep: still, with a low and steady heart rate. More likely early in the night.
  • REM: no big movements, with a higher and irregular heart rate. More likely late in the night, and never in the first hour.
  • Light sleep: everything else.

Stages need heart rate over most of the night, and stage sensors carry experimental: true. Expect them to agree with a sleep lab about 60–70 % of the time at best. Their best use is spotting trends across nights.

The live sleep stage (sensor.<name>_sleep_stage: out of bed, awake, asleep, light, deep or REM) uses the same rules as the night goes, for automations. It can only look back, so it judges each moment on the 5 minutes before it and runs a few minutes behind. A new stage shows once it has held for 3 minutes.

  • Heart rate is ranked against the night so far and the previous night. The first night after setup has only itself to go on, so it shows less deep sleep.
  • Out of bed or awake, it says so, from the bed sensor.
  • Asleep with no heart rate (the ring is off, say), it says asleep: the bed sensor knows you're asleep, but movement alone can't tell deep sleep from REM, since you lie still in both. Any heart-rate sensor works, a radar's included.
  • The night's report works the stages out again over the whole night, and those are the ones to trust.

Events and actions

Events:

  • sleep_tracker_session_started
  • sleep_tracker_fell_asleep (first: true for the first sleep of the night)
  • sleep_tracker_woke_up
  • sleep_tracker_thermal (out of the comfort band for 10 min while asleep)
  • sleep_tracker_smart_wake (see below)
  • sleep_tracker_alert (see below)
  • sleep_tracker_screening_notice (see below)
  • sleep_tracker_session_ended (full summary in summary)

Actions:

  • sleep_tracker.start_session / end_session / discard_session, for a bedside button or NFC tag.

  • sleep_tracker.recompute_session: re-runs a stored night with the current settings and returns the summary. A stored night keeps the sensor values as they were read at the time, so settings that change how the sensors are read (such as wearable motion) don't reach it. Add from_history: true to rebuild the night from the recorder instead, so every current setting applies. That needs the night to still be in the recorder (10 days by default), and doesn't work for a night started by hand.

  • sleep_tracker.import_samples: backfills a night from a device recording (for example the PO6's stored night):

    action: sleep_tracker.import_samples
    data:
      sleeper: binary_sensor.alex_in_bed   # the sleeper's device id, or any of its entities
      source: viatom_po6
      samples:
        - {t: "2026-11-04T02:13:04Z", spo2: 95, heart_rate: 58, movement: 0, worn: true}
    

    Fields: t (ISO time or epoch seconds) plus any of heart_rate, spo2, movement, worn, bed_temperature and bed_humidity. Live data always wins, only gaps are filled, and re-sending the same recording is harmless.

    With viatom_oximeter 0.4 or later this happens on its own for the PO6: each recording it downloads from the ring is imported into the night it covers. That fills Bluetooth gaps, and nights the SleepImage app held the ring. The ring's own figures (its 3 % and 4 % dips, time below 90 %, O2 score) are kept with the night, under device_reports in its summary, one entry per recording: the ring can save a night in several pieces.

Download diagnostics (on the integration's page) gives the bed setup in the format tools/replay.py reads.

Apnea screening (opt-in, experimental)

Disclaimer. This is a home screening estimate from a consumer oximeter, not a medical device. It cannot diagnose sleep apnea and it cannot rule it out. Oximetry misses breathing events that don't lower oxygen, and a loose ring or movement can create false events. If you snore loudly, stop breathing or gasp in your sleep (as noticed by a partner), wake unrefreshed, or are very sleepy during the day, talk to a doctor, whatever this shows.

It's off by default. To turn it on, open Configure → Apnea screening (opt-in), which shows the disclaimer above, and tick I understand. It needs SpO2 (the PO6).

Each night then gets:

Sensor What it is
Estimated breathing-event index 3 % SpO2 drops per hour of sleep, counting only drops with something backing them up: your pulse jumping 6+ bpm just after (a brief arousal), or you moving right after. Drops that start while you're moving are ignored as artefacts. Its attributes add the 7- and 30-night medians (single nights vary a lot), all 3 % drops per hour, and the cyclic clusters.
Screening range (estimate) Below 5, 5 to 15, 15 to 30, or 30 or more events per hour: the cut-offs clinicians use. With under 4 hours of SpO2 while asleep, it says "not enough data". It never says mild, moderate or severe.
Hypoxic burden How deep and long the drops were, added up, per hour (%·min/h).

Cyclic clusters are runs of three or more drops each 30–90 seconds apart, the pattern typical of obstructive events. The card shows all of this in a Breathing screening section (with the disclaimer one tap away), marks the events on the SpO2 track, and charts the index on the Trends tab.

Optionally, under the same settings, you can be told when 4 of the last 7 nights reach a range you choose. The sleep_tracker_screening_notice event fires, and the message also goes to the sleeper's alert notify target. It's at most once a week, and it suggests talking to a doctor; it's never a result. Comparing it with SleepImage reports or a past sleep test is the best check.

The same settings can also hide the disclaimers on the card: its footnote and the note under the screening figures. The settings screen and the entities still show them.

Alerts

While someone is in bed, the sleep_tracker_alert event fires for:

alert When Ignored when
low_spo2 SpO2 stays below 88 % for 60 s the wearable isn't worn, you're moving (likely an artefact), or the heart rate is implausible
low_heart_rate, high_heart_rate below 40 or above 120 bpm for 2 min while asleep you're awake
wearable_lost the wearable stops reporting for 5 min it wasn't worn at all that night
bed_sensor_lost the bed pressure sensor is unavailable for 5 min the bed sensor is only quiet (a still sleeper's strap sends little)
bed_battery_low, wearable_battery_low at bedtime, a mapped battery sensor is below 15 %
breathing_stopped (experimental) a mapped breathing rate reads 3 breaths/min or less for 20 s while you're lying still breathing wasn't seen earlier that night, you're moving, or the sensor is unavailable

The event carries the sleeper, value, threshold and a ready-made message. The same alert repeats at most every 15 minutes while it lasts, a lost sensor alerts once per outage, and each breathing stop alerts once. Each sleeper's Alerts switch turns them off; its last_alert attribute shows the latest. Configure → Alerts sets each tracked sleeper's thresholds, how long a reading has to last, and an optional notify action or entity to also send their alerts to (for example notify.mobile_app_your_phone), so each of you can get your own alerts on your own phone. The battery sensors are mapped under Configure → Sensors.

Breathing stopped is experimental. It needs a sensor that reports breaths per minute, such as a 60 GHz radar, mapped as Breathing rate under Configure → Sensors. The PO6 doesn't measure breathing. It's checked the moment the 20 seconds are up (adjustable under Configure → Alerts), not at the next 30-second step. Radars often lose the breathing signal when you change position, so expect false alarms, and it can miss real stops. Never rely on it to keep anyone safe: it is no substitute for medical monitoring. Like all of these alerts, it's a convenience, not a medical alarm.

Smart wake

Each sleeper chooses what smart wake aims for, under Configure → Sensors:

  • A wake-up time (the default): set it with the sleeper's Wake-up time entity (07:00 by default).
  • The phone's next alarm: the Companion app's next_alarm sensor, mapped as Next alarm on the same page. A night without an alarm set gets no smart wake, and there's no Wake-up time entity.

Turn on the sleeper's Smart wake switch. In the Smart wake window before the wake-up time or alarm (30 minutes by default), the sleep_tracker_smart_wake event fires once, at the first sign of lighter sleep:

  • awake: they're already awake in bed
  • stirring: small movements
  • heart_rate: their heart rate rises well above its sleeping level (with a wearable)
  • window_end: none of these happened, so it fires at the wake-up time

It doesn't fire once they're out of bed, or for a nap that started inside the window. The switch's target and window_start attributes show tonight's wake-up time and when the window opens.

Pre-wake. The nudge's moment is picked live, so it isn't known ahead of time. To start something slowly before it, like a sunrise, use the sleep_tracker_pre_wake event. It fires once, Pre-wake lead minutes before the window opens (0 by default, so as it opens). So it always comes before the nudge. It follows the same rules and carries the same target and window_start. The switch's pre_wake attribute shows when it's due.

What happens next is up to your automations: lights, blinds, heating, a speaker, anything. For example, a sunrise that starts at the pre-wake and ramps up until the wake-up time:

alias: Smart wake - sunrise
triggers:
  - trigger: event
    event_type: sleep_tracker_pre_wake
    event_data:
      sleeper: Alex
actions:
  - action: light.turn_on
    target:
      entity_id: light.bedroom
    data:
      brightness_pct: 40
      # Seconds from now until the wake-up time
      transition: "{{ (as_timestamp(trigger.event.data.target) - as_timestamp(now())) | int }}"

Then the nudge finishes it, whenever it comes:

alias: Smart wake - wake up
triggers:
  - trigger: event
    event_type: sleep_tracker_smart_wake
    event_data:
      sleeper: Alex
actions:
  - action: light.turn_on
    target:
      entity_id: light.bedroom
    data:
      brightness_pct: 100
      transition: 60
  - action: cover.open_cover
    target:
      entity_id: cover.bedroom_blinds

Sleep report card

The sleep report card in the light theme: the live strip, a night's times and headline figures with their targets, the sleep timeline, heart rate, SpO2 with drops shaded, movement, temperature, and the night's other figures

The card in the dark theme, with the readout for 05:31 open on the timeline: asleep, heart rate 57 bpm, SpO2 88 %, bed 31 °C, room 19 °C   The Trends tab over 7 nights: average score, sleep, usual bedtime and wake time, then bars for the score, sleep by stage against the target, the schedule from bedtime to getting up, and SpO2 drops per hour   The top of the card at phone width: the live strip, the score and the tiles in two columns

Dark theme, with the values at one moment (hover or tap the timeline) · The Trends tab (tap a night to open it) · Phone width

The screenshots show synthetic nights generated by the engine (tests/card/fixture.py).

The card ships with the integration and loads on every dashboard, so there's nothing else to install. Edit dashboard → Add card → Sleep report, then pick the sleeper. In YAML:

type: custom:sleep-tracker-card
device_id: 0123456789abcdef   # the sleeper's device
title: Alex                   # optional
view: night                   # the tab it opens on: night or trends
show_heart_rate: true         # tracks, shown when there's data; all default to true
show_spo2: true
show_movement: true
show_temperature: true

It has two tabs.

Night:

  • The most recent night: times, the sleep score with its parts, four headline figures, and every figure the night has.
  • Targets: the headline figures show common adult targets (7–9 h of sleep, efficiency 85 % or more, falling asleep in 30 min or less, 20 min or less awake in the night), with a dot that's green on target, amber a little off and red well off. SpO2 figures show their typical range, marked only when the night falls outside it. Sleep stages and heart rate have no target: the stages are rough estimates, and heart rate varies from person to person.
  • A timeline with the sleep stages (deep, light, REM, awake and out of bed, told apart by height as well as colour), plus heart rate, SpO2 with drops shaded, movement and temperatures. Hover or tap the timeline for the values at that moment. Without heart rate, the timeline shows asleep, awake and out of bed.
  • Earlier nights with ‹ ›. Timelines are kept for the last 14 nights; older nights show their figures.
  • While you're in bed, a live strip with the current state and stage estimate, heart rate and SpO2, and the smart wake window when it's on.

Trends, over the last 7 or 30 nights:

  • Average score and sleep (against the 7–9 h target), your usual bedtime (and how much it varies) and usual wake time.
  • Bars for the score, sleep by stage against your target, your schedule from bedtime to getting up, and SpO2 drops per hour.
  • How your nights compare by room and bed temperature, in 2 °C bands from your comfort band: nights, average score, sleep and time awake. These are statistics, not causes, but they show whether you sleep worse when it's warm.
  • Tap a night to open it in the Night tab.

The card follows the dashboard theme. Its colours can be overridden with theme variables (sleep-tracker-asleep-color, -good-color, -fair-color, -poor-color, -deep-color, -light-color, -rem-color, -awake-color, -out-color, -heart-rate-color, -spo2-color, -drop-color, -bed-color, -room-color, -movement-color).

Example dashboard

docs/dashboard.yaml is a ready-made dashboard with three columns:

  • Tonight: the live state and thermal state, the smart wake controls and the alerts switch.
  • Last night: the report card.
  • Trends: the Trends card and a 30-day sleep score graph.

Create an empty dashboard, open its raw configuration editor and paste the file. Then replace alex with your sleeper's name as it appears in the entity ids, and SLEEPER_DEVICE_ID with the sleeper's device (or pick the sleeper in each card's editor).

Install

Unzip the zip from the latest release (sleep_tracker-<version>.zip) into your config directory, or, once the GitHub mirror is up, install with HACS. Needs Home Assistant 2026.2 or later. docs/install.md has the steps for HA OS, Supervised, Container and Core, plus the ZG-210Z / Zigbee2MQTT settings and calibration. Sensors can be changed later under Configure → Sensors. Calibrate the bed sensor once it's set up; until then the empty-bed level is learnt from the lowest reading seen, which only works if Home Assistant sees the bed empty at least once.

Tuning with real nights

tools/replay.py runs exported Home Assistant history through the same engine, outside Home Assistant.

Development

python3 -m venv .venv && .venv/bin/pip install -r requirements_test.txt
scripts/ci.sh

scripts/ci.sh runs every check: ruff, mypy (strict for the engine) and pytest. The Forgejo and GitHub workflows both call it.

  • Engine (custom_components/sleep_tracker/engine/): pure Python with no Home Assistant imports. Its tests use scripted synthetic nights (tests/engine/synth.py); the integration tests run in a Home Assistant test instance.
  • Card (custom_components/sleep_tracker/frontend/sleep-tracker-card.js): plain JavaScript with no build step.
    • Its tests run in headless Chromium, and are skipped when Chromium isn't installed.
    • To look at it by hand, run python3 tests/card/fixture.py, then open tests/card/harness.html. Add ?theme=dark, ?live=1, ?width=375 or ?cursor=0.6 to change the view.
    • After changing the card, python3 scripts/card_screenshots.py regenerates the screenshots above. It needs Chromium and ImageMagick.
  • Translations (English and French so far), in three places. Each language must have every English key and {placeholder}, or the tests fail.
    • Screens and entities: strings.json is the English source, translations/en.json a copy of it, and each other language a file in translations/. Home Assistant picks them by the user's language.
    • Text built in Python (the feature list, alert messages, notices, entity notes): i18n.py, in Home Assistant's own language (Settings → System → General).
    • The card: TEXT in the card's JavaScript, in the user's language. Add ?lang=fr to the harness to see it.
  • Brand images: the drawings are in assets/brand/source/. scripts/brand.py builds the trimmed icon.svg, logo.svg and dark_logo.svg in assets/brand/. From those it renders the PNGs Home Assistant reads (custom_components/sleep_tracker/brand/). It needs librsvg and ImageMagick.
  • Releases: scripts/package.py builds the install zip into dist/. Pushing a v* tag that matches the manifest version publishes a release with the zip, on Forgejo and on GitHub, with that version's section of CHANGELOG.md as its notes. Add the section before tagging: the release fails without one.

License

MIT