Skip to content

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.

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.

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:

Terminal window
docker compose --profile tools run --rm token -subject home-assistant

Put it in secrets.yaml, never in configuration.yaml:

secrets.yaml
helsa_token: "eyJhbGciOiJIUzI1NiIs..."
helsa_base: "https://helsa.lan:8443/v1"
configuration.yaml
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:fire

with, 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: measurement

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.
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."
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.

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.