REST fallback
If you do not want an MQTT broker, Home Assistant can poll the API directly. It works, it needs no extra components, and it costs you hand-written YAML with no discovery.
deploy/scripts/sync-heartbeat.sh is the push-shaped variant of the same idea: a
cron job on the Helsa host that writes the freshness into a Home Assistant sensor
over the REST API. Either is fine; running one of them alongside the MQTT
publisher is not, because then one number has two sources that can disagree.
The same rule applies as everywhere else: poll summaries, not raw samples.
Reaching the API
Section titled “Reaching the API”Home Assistant must be able to reach Helsa on the LAN or VPN interface — the one that does not demand a client certificate. Home Assistant’s REST integration cannot present a client certificate, so it cannot use the public mutual-TLS interface at all.
That is a feature. The public interface stays limited to devices holding a certificate you issued.
Trusting the private CA
Section titled “Trusting the private CA”The server certificate is signed by your own CA, which Home Assistant does not know
about. Install ca.crt into the trust store of the container or OS running Home
Assistant so that certificate verification succeeds.
Issue a dedicated device token so it can be revoked without affecting your phone:
docker compose --profile tools run --rm token -subject home-assistantPut it in secrets.yaml, never in configuration.yaml:
helsa_token: "eyJhbGciOiJIUzI1NiIs..."helsa_base: "https://helsa.lan:8443/v1"Sensors
Section titled “Sensors”rest: - resource: !secret helsa_base_summary_today scan_interval: 1800 # 30 minutes. Do not make this small. timeout: 20 headers: Authorization: !secret helsa_auth_header Accept: application/json sensor: - name: "Helsa steps today" unique_id: helsa_steps_today value_template: "{% raw %}{{ value_json.metrics.stepCount.total | round(0) }}{% endraw %}" unit_of_measurement: "steps" state_class: total_increasing icon: mdi:walk - name: "Helsa active energy today" unique_id: helsa_active_energy_today value_template: "{% raw %}{{ value_json.metrics.activeEnergy.total | round(0) }}{% endraw %}" unit_of_measurement: "kcal" state_class: total_increasing icon: mdi:firewith, in secrets.yaml:
helsa_base_summary_today: "https://helsa.lan:8443/v1/summary?range=day&metrics=stepCount,activeEnergy&tz=Europe/Budapest"helsa_auth_header: "Bearer eyJhbGciOiJIUzI1NiIs..."Pass tz explicitly. Without it the server falls back to the stored user setting,
and if those two ever disagree your daily totals will be cut at a different
midnight than you expect.
rest: - resource: !secret helsa_base_summary_sleep scan_interval: 3600 headers: Authorization: !secret helsa_auth_header sensor: - name: "Helsa sleep last night" unique_id: helsa_sleep_last_night value_template: >- {% raw %}{{ (value_json.metrics.sleepHours.total | float(0)) | round(1) }}{% endraw %} unit_of_measurement: "h" device_class: duration state_class: measurementFreshness, without MQTT
Section titled “Freshness, without MQTT”GET /v1/devices returns devices ordered by last_seen_at. Poll it and compute
the age of the newest entry:
rest: - resource: !secret helsa_base_devices scan_interval: 900 # 15 minutes headers: Authorization: !secret helsa_auth_header sensor: - name: "Helsa sync freshness" unique_id: helsa_sync_freshness unit_of_measurement: "h" state_class: measurement value_template: >- {% raw %}{% set ios = value_json | selectattr('platform', 'eq', 'ios') | list %} {% if ios | count > 0 %} {{ [ ((now().timestamp() - (ios | map(attribute='last_seen_at') | max | as_datetime | as_timestamp)) / 3600), 0 ] | max | round(1) }} {% else %} unknown {% endif %}{% endraw %}Two things this template does deliberately:
- It filters to
platform == 'ios'. Only the phone uploads. An iPad or Mac checking in would otherwise keep the sensor looking healthy while the phone had not synced for weeks — precisely the failure being watched for. - It clamps at zero. If a clock is skewed and the newest timestamp is in the future, the age goes negative, and a negative number never crosses an “above 12” threshold. That silently disables the alert.
The important limitation
Section titled “The important limitation”automation: - alias: Helsa sync stalled (REST) trigger: - platform: numeric_state entity_id: sensor.helsa_sync_freshness above: 12 for: "00:30:00" - platform: state entity_id: sensor.helsa_sync_freshness to: ["unavailable", "unknown"] for: "01:00:00" action: - service: notify.mobile_app_your_phone data: title: Helsa is not syncing message: "No fresh health data from the phone."Polling budget
Section titled “Polling budget”| Endpoint | Sensible interval | Why not faster |
|---|---|---|
/v1/summary?range=day |
30 minutes | Answers come from continuous aggregates refreshed on a schedule. Polling every minute returns the same number 30 times. |
/v1/summary?range=week |
6 hours | It is a weekly figure. |
/v1/devices |
15 minutes | Enough resolution for a 12-hour threshold. |
/v1/samples |
Never | This is the raw-data endpoint. It is not for Home Assistant. |
When to switch to MQTT
Section titled “When to switch to MQTT”If you find yourself adding a fourth REST sensor, or writing templates to reshape responses, the broker will save you time. Discovery means the entity definitions live with the publisher rather than in your Home Assistant configuration, and the freshness alert becomes a genuine dead man’s switch.