Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 97 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,27 @@
# Polar integration for Home Assistant

This a _custom component_ for [Home Assistant](https://www.home-assistant.io/).
The `polar` integration allows you to get information from [Polar](https://flow.polar.com).
This is a _custom component_ for [Home Assistant](https://www.home-assistant.io/).
The `polar` integration pulls your data from [Polar](https://flow.polar.com) via
the [Polar AccessLink API](https://www.polar.com/accesslink-api/) and exposes it
as sensors — sleep, recovery, heart rate and training load — and, importantly,
**backfills recent history** so you don't only see data starting from the moment
you installed the integration.

You need to create a Client in [Polar Access Link](https://admin.polaraccesslink.com) and set in `Authorization redirect URLs`:
## Features

* `https://your_external_access_to_ha`
* `https://your_external_access_to_ha/api/polar_auth` (selected)
- **OAuth2 setup** against Polar AccessLink, with a **connection test** when you
add your credentials: it checks connectivity to Polar and that your `Client ID`
is accepted, logs the result, and reports a clear error up front instead of
failing later during the OAuth exchange.
- **Rich sensors**: continuous heart rate, heart rate variability, breathing
rate, deep/light/REM sleep, sleep score, cardio (training) load, daily
activity, weight and last-exercise stats.
- **History backfill**: imports up to ~28 days of Polar history (and 7 days of
hourly heart rate) as long-term statistics **on the sensors themselves**, so
each sensor's *History* page shows the past — including data from before the
integration was installed.
- **Ready-to-use dashboard** for training + sleep tracking
([`polar-dashboard.yaml`](./polar-dashboard.yaml)).

## Installation

Expand All @@ -20,13 +35,86 @@ Copy the `custom_components/polar` folder into the config folder.

## Configuration

To add the Polar integration to your installation, go to Configuration >> Integrations in the UI, click the button with + sign and from the list of integrations select Polar.
You need to create a Client in [Polar Access Link](https://admin.polaraccesslink.com)
and set in `Authorization redirect URLs`:

* `https://your_external_access_to_ha`
* `https://your_external_access_to_ha/api/polar_auth` (selected)

To add the Polar integration to your installation, go to Settings >> Devices &
Services in the UI, click the **+ Add Integration** button and select Polar.

### Fields

* `Client ID` and `Client secret`: get credentials grom [Polar Access Link](https://admin.polaraccesslink.com).
* `Scan Interval` interval in minutes between two scan to Polar API (default: `30`)
* `URL`: URL used to access to your Home-Assistant (default: your external or internal URL if configured in HA settings)
* `Client ID` and `Client secret`: get credentials from [Polar Access Link](https://admin.polaraccesslink.com).
* `Scan Interval`: interval in minutes between two scans of the Polar API (default: `30`).
* `URL`: URL used to access your Home Assistant (default: your external or internal URL if configured in HA settings).

When you submit your credentials the integration first runs a connection test
(reachability to Polar + that the `Client ID` is accepted) and logs the result,
so a wrong URL, missing connectivity or an invalid client is reported up front
with a clear error instead of failing later during the OAuth exchange.

## Sensors

| Sensor | Description |
| --- | --- |
| `Heart rate` | Latest continuous heart rate sample (min / max / average of the day as attributes) |
| `Heart rate variability` | Average HRV (RMSSD, ms) from the last Nightly Recharge |
| `Breathing rate` | Average breathing rate from the last Nightly Recharge |
| `Deep sleep` / `Light sleep` / `REM sleep` | Sleep-stage durations (selectable unit, plus a human-readable `Xh Ym` `duration` attribute) |
| `Last sleep score` | Sleep score 1–100, with all sleep details as attributes |
| `Last nightly recharge` | Nightly Recharge status, with ANS charge / HRV / breathing as attributes |
| `Cardio load` | Training load, with `strain`, `tolerance`, `cardio_load_ratio`, `cardio_load_status` |
| `Last exercise` | Start time of the last training session, with distance / duration / sport / calories |
| `Last exercise heart rate average` / `… maximum` | Heart rate stats of the last training session |
| `Daily activity Steps` / `Calories` / `Duration` | Daily activity summary |
| `Weight` | Latest weight |

Continuous heart rate and cardio load depend on device support and the user's
Polar consents; when unavailable they simply stay unknown — they never break the
rest of the update.

## History backfill

Home Assistant sensors only build history forward from the moment they start
polling and **cannot** backfill past states. Polar, however, keeps the last
~28 days of nightly/daily data and per-day continuous heart rate samples.

On setup (and once a day afterwards) the integration imports that history as
**long-term statistics attached to the sensors themselves**, so each sensor's
*History* page shows the past — including data from before the integration was
installed:

* sleep stages, sleep score, HRV, breathing rate and cardio load → one point per
day for the last ~28 days;
* continuous heart rate → hourly min / mean / max for the last 7 days.

The import is best-effort: a metric the device doesn't provide (or a day with no
data) is logged and skipped, never failing the rest of the update. This requires
the `recorder` integration (declared as a dependency).

> Note: only the long-term statistics are backfilled. The instantaneous state
> graph still only moves forward — Home Assistant does not allow inserting past
> raw states.

## Dashboard

[`polar-dashboard.yaml`](./polar-dashboard.yaml) is a ready-to-use Lovelace
dashboard for training + sleep tracking:

* **Today** — a live snapshot (heart rate, HRV, breathing, weight, last night's
sleep stages and recharge, last workout), a sections view built with the
native `tile` cards and badges;
* **Sleep** — 28-day history of sleep stages, sleep score, HRV and breathing;
* **Training** — 28-day cardio load and 7-day continuous heart rate, plus the
live heart rate of the day.

It uses only built-in cards (sections view, `tile`, `statistics-graph`) — no
custom HACS cards required. Paste it into a new dashboard via the raw
configuration editor. The entity ids carry your Polar device name as a prefix
(e.g. `sensor.polar_loop_deep_sleep`); if yours differ, find-replace the prefix
in the editor — see the comments at the top of the file.

## Credits

Expand Down
19 changes: 19 additions & 0 deletions custom_components/polar/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,22 @@

from __future__ import annotations

from datetime import timedelta
import logging

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.event import async_track_time_interval

from .const import CONF_USER_ID, DOMAIN
from .coordinator import PolarCoordinator
from .statistics import async_import_history

_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.SENSOR]
HISTORY_IMPORT_INTERVAL = timedelta(hours=24)


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
Expand All @@ -37,6 +41,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

async def _import_history(now=None) -> None:
"""Backfill Polar history into long-term statistics (best effort)."""
try:
await async_import_history(hass, coordinator, entry)
except Exception: # noqa: BLE001 - never fail setup on history import
_LOGGER.exception("Polar: failed to import historical statistics")

# Run once now (in the background) and then refresh once a day.
entry.async_create_background_task(
hass, _import_history(), "polar_history_import"
)
entry.async_on_unload(
async_track_time_interval(hass, _import_history, HISTORY_IMPORT_INTERVAL)
)

return True


Expand Down
86 changes: 72 additions & 14 deletions custom_components/polar/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
DEFAULT_SCAN_INTERVAL,
DOMAIN,
)
from .polaraccesslink.accesslink import AccessLink
from .polaraccesslink.accesslink import AUTHORIZATION_URL, AccessLink

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -67,29 +67,87 @@ def __init__(self) -> None:
self.external_data: dict[str, Any] = {}
self.accesslink: AccessLink

def _show_user_form(
self, default_external_url: str | None, error: str | None = None
) -> ConfigFlowResult:
"""Show the user step form, optionally with an error."""
return self.async_show_form(
step_id="user",
description_placeholders={
"polar_admin_url": ADMIN_URL,
},
data_schema=_get_user_data_schema(default_external_url),
errors={"base": error} if error else None,
)

def _test_connection(self) -> str | None:
"""Check connectivity to Polar and that the client_id is accepted.

Runs in an executor (uses blocking ``requests``). Returns an error key
to show on the form, or ``None`` when the connection looks good.
"""
auth_url = self.accesslink.get_authorization_url()
_LOGGER.info(
"Polar: testing connection to authorization endpoint %s", AUTHORIZATION_URL
)
try:
response = requests.get(auth_url, timeout=30, allow_redirects=False)
except requests.exceptions.RequestException as err:
_LOGGER.error(
"Polar: connection test FAILED, cannot reach Polar (%s): %s",
AUTHORIZATION_URL,
err,
)
return "cannot_connect"

body = (response.text or "")[:500]
if "invalid_client" in body.lower() or response.status_code in (400, 401):
_LOGGER.error(
"Polar: connection test FAILED, client credentials rejected "
"(HTTP %s): %s",
response.status_code,
body,
)
return "invalid_auth"

if response.status_code >= 500:
_LOGGER.error(
"Polar: connection test FAILED, Polar server error (HTTP %s)",
response.status_code,
)
return "cannot_connect"

_LOGGER.info(
"Polar: connection test OK (HTTP %s), client_id accepted, "
"proceeding to OAuth login",
response.status_code,
)
return None

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
if user_input is None:
self.hass.http.register_view(PolarAuthCallbackView())

return self.async_show_form(
step_id="user",
description_placeholders={
"polar_admin_url": ADMIN_URL,
},
data_schema=_get_user_data_schema(
self.hass.config.external_url or self.hass.config.internal_url
),
return self._show_user_form(
self.hass.config.external_url or self.hass.config.internal_url
)

self.data = user_input
self.accesslink = AccessLink(
client_id=self.data[CONF_CLIENT_ID],
client_secret=self.data[CONF_CLIENT_SECRET],
redirect_url=_get_callback_url(user_input[CONF_EXTERNAL_URL]),
)
try:
self.accesslink = AccessLink(
client_id=self.data[CONF_CLIENT_ID],
client_secret=self.data[CONF_CLIENT_SECRET],
redirect_url=_get_callback_url(user_input[CONF_EXTERNAL_URL]),
)
except ValueError as err:
_LOGGER.error("Polar: invalid client configuration: %s", err)
return self._show_user_form(user_input[CONF_EXTERNAL_URL], "invalid_auth")

if error := await self.hass.async_add_executor_job(self._test_connection):
return self._show_user_form(user_input[CONF_EXTERNAL_URL], error)

return await self.async_step_oauth()

Expand Down
3 changes: 3 additions & 0 deletions custom_components/polar/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@
ATTR_RECHARGE_DATA = "rechargedata"
ATTR_USER_DATA = "userdata"
ATTR_DAILY_DATA = "dailydata"
ATTR_CARDIO_LOAD_DATA = "cardioloaddata"

ATTR_LAST_EXERCISE = "last_exercise"
ATTR_LAST_SLEEP = "last_sleep"
ATTR_LAST_DAILY = "last_daily"
ATTR_LAST_RECHARGE = "last_recharge"
ATTR_LAST_CARDIO_LOAD = "last_cardio_load"
ATTR_CONTINUOUS_HEART_RATE = "continuous_heart_rate"

AUTH_CALLBACK_NAME = "api:polar_auth"
AUTH_CALLBACK_PATH = "/api/polar_auth"
Expand Down
13 changes: 13 additions & 0 deletions custom_components/polar/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator

from .const import (
ATTR_CARDIO_LOAD_DATA,
ATTR_CONTINUOUS_HEART_RATE,
ATTR_DAILY_DATA,
ATTR_EXERCISE_DATA,
ATTR_LAST_CARDIO_LOAD,
ATTR_LAST_DAILY,
ATTR_LAST_EXERCISE,
ATTR_LAST_RECHARGE,
Expand Down Expand Up @@ -89,14 +92,24 @@ async def _async_update_data(self) -> dict:
f".storage/polar_dailydata_{self._entry.entry_id}.json"
),
)
cardioloaddata = await self.hass.async_add_executor_job(
self.accesslink.get_cardio_load, self._entry.data[CONF_ACCESS_TOKEN]
)
continuousheartrate = await self.hass.async_add_executor_job(
self.accesslink.get_continuous_heart_rate,
self._entry.data[CONF_ACCESS_TOKEN],
)
return {
ATTR_USER_DATA: userdata,
ATTR_EXERCISE_DATA: exercisedata,
ATTR_SLEEP_DATA: sleepdata,
ATTR_RECHARGE_DATA: rechargedata,
ATTR_DAILY_DATA: dailydata,
ATTR_CARDIO_LOAD_DATA: cardioloaddata,
ATTR_LAST_EXERCISE: next(iter(exercisedata), {}),
ATTR_LAST_SLEEP: next(iter(sleepdata), {}),
ATTR_LAST_RECHARGE: next(iter(rechargedata), {}),
ATTR_LAST_DAILY: next(iter(dailydata), {}),
ATTR_LAST_CARDIO_LOAD: next(iter(cardioloaddata), {}),
ATTR_CONTINUOUS_HEART_RATE: continuousheartrate,
}
3 changes: 2 additions & 1 deletion custom_components/polar/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
],
"config_flow": true,
"dependencies": [
"http"
"http",
"recorder"
],
"documentation": "https://github.com/Aohzan/hass-polar",
"integration_type": "service",
Expand Down
Loading
Loading