Skip to content

MQTT support

OliE edited this page Jun 14, 2026 · 9 revisions

openScale Sync — MQTT integration

openScale Sync pushes body-measurement data to an MQTT broker in real time (on insert / update / delete / clear) and on a manual full sync. The payload is formatted in JSON. All measurement data is associated with the measurement ID and user ID from the openScale app.

You can test openScale Sync's MQTT support with any MQTT 5 / 3.1 compatible broker, like the public broker from HiveMQ.

Note

openScale Sync uses the MQTT 5 protocol but should also work with the MQTT 3.1 protocol.

Important

The date and time are represented as a formatted string with timezone information (e.g. 2025-12-27T10:30+0100). Weight is in kilograms (kg), while body fat, muscle mass and body water are in percent (%). Conversions to other units must be handled by the respective program.

Important

Version requirements. The multi-user, per-user topics (openScaleSync/<userId>/…), the retained …/history topic and the per-user Home Assistant devices were introduced in openScale Sync 0.5. Earlier versions used flat topics (openScaleSync/measurements/<event>) and a …/measurements/all stream. Full multi-user routing (the correct userId on every event, including delete/clear) and the self-describing values array — including custom metric types — additionally require openScale with ContentProvider API version 2 or newer. With an older openScale build the values array is absent and the userId may not be carried on delete/clear.

Topic structure (multi-user)

Every topic is per user — the stable openScale userId is the second path segment:

openScaleSync/<userId>/measurements/<event>

where <event> is insert, update, delete, clear, last or history. The human-readable user name travels inside the JSON payload (username); the topic always uses the numeric id.

In addition to the convenience fields weight / body_fat / water / muscle, each measurement payload carries a self-describing values array containing all metric types — including user-defined custom ones (key: "CUSTOM"). The convenience fields are derived from this array. (The values array requires openScale ContentProvider API v2+; see the version note above.)

Topics & Payloads

Add a measurement

Published when a new measurement is saved in openScale.

  • Topic: openScaleSync/<userId>/measurements/insert
  • Payload:
{
  "id": 1,
  "userId": 1,
  "date": "2025-12-27T10:30+0100",
  "weight": 80.5,
  "body_fat": 22.5,
  "water": 55.8,
  "muscle": 35.1,
  "username": "Alice",
  "values": [
    { "typeId": 1, "key": "WEIGHT",   "name": "Weight",   "unit": "kg", "isDerived": false, "value": 80.5 },
    { "typeId": 2, "key": "BODY_FAT", "name": "Body fat", "unit": "%",  "isDerived": false, "value": 22.5 },
    { "typeId": 9, "key": "CUSTOM",   "name": "Waist",    "unit": "cm", "isDerived": false, "value": 88.0 }
  ]
}

Update a measurement

Published when an existing measurement is edited.

  • Topic: openScaleSync/<userId>/measurements/update
  • Payload: same format as insert.

Delete a measurement

Published when a measurement is deleted.

  • Topic: openScaleSync/<userId>/measurements/delete
  • Payload:
{
  "dateTime": "2025-12-27T10:30+0100"
}

Clear all measurements

Published when all measurements for a user are deleted from within openScale.

  • Topic: openScaleSync/<userId>/measurements/clear
  • Payload: true

Last (latest) measurement

Always contains the user's most recent measurement; updated after any insert, update or delete. Useful for dashboards or services like Home Assistant that only need the current state. Sent with the retain flag set to true.

  • Topic: openScaleSync/<userId>/measurements/last
  • Payload: same format as insert, or an empty object {} after the last measurement is cleared.

History (full series)

Published on a manual full sync, and by the periodic background sync whenever a user's data changed. Carries the user's complete, date-sorted series as a single retained, slim columnar message — so a consumer that connects later still receives the whole history at once. This replaces the former …/measurements/all stream.

The header lists the column names/units once (not per row), keeping the payload compact (~10× smaller than repeating the full per-measurement JSON). date is epoch milliseconds; rows are sorted ascending by date; a cell is null when that measurement lacks the metric. weight / body_fat / water / muscle are normalized (kg / % / % / %); every other openScale metric — including custom types (custom_<id>) — appears as an additional column.

  • Topic: openScaleSync/<userId>/measurements/history
  • Payload:
{
  "fields": ["date", "weight", "body_fat", "water", "muscle", "waist", "custom_5"],
  "units":  { "weight": "kg", "body_fat": "%", "water": "%", "muscle": "%", "waist": "cm", "custom_5": "cm" },
  "rows": [
    [1735292400000, 80.5, 22.5, 55.8, 35.1, 88.0, null],
    [1735378800000, 81.0, 22.3, 55.9, 35.2, 87.5, 12.0]
  ]
}

Home Assistant Integration

openScale Sync supports MQTT Discovery for Home Assistant. If enabled, the app automatically publishes configuration messages that create and configure sensor entities — one device per openScale user.

The discovery configuration is published (retained) to:

homeassistant/device/openscale_<userId>/config

Each user's device is named openScale (<username>) with identifier openscale_<userId>, and its entities' unique_ids are suffixed with _<userId> so Home Assistant keeps users separate. The sensors read their state from that user's retained openScaleSync/<userId>/measurements/last topic (e.g. the body-fat sensor uses value_json.body_fat). The following sensors are created by default:

  • Weight
  • Body Fat
  • Muscle Mass
  • Body Water
  • Measurement Date
  • Measurement ID

The discovery payload also includes an origin object with the sw_version of the openScale Sync app and the openScale source version on the device page.

Backfilling historical data (long-term statistics)

A Home Assistant MQTT sensor only ever holds the current value: its state is stamped with the time HA receives it, so publishing old measurements cannot backfill the past. The only supported way to inject a historical timeline is long-term statistics (start-stamped hourly buckets) via the recorder.import_statistics service.

The blueprint below consumes the retained …/measurements/history topic and imports each user's series into their discovery sensors, so historical weight / body fat / water / muscle show up in Home Assistant's history & statistics cards with their real timestamps.

Requirements

  • The Spook integration — it provides the recorder.import_statistics service (Home Assistant core does not expose it as a service).
  • The openScale Sync MQTT backend with Home Assistant discovery enabled (so the target sensors exist with state_class: measurement).

Setup

  1. Install Spook.
  2. Add the blueprint (Settings → Automations & Scenes → Blueprints → Import / create from YAML).
  3. Create one automation per openScale user: enter that user's numeric userId (the topic path segment) and pick that user's weight / body-fat / water / muscle sensors. Leave a sensor empty to skip that metric. (The target entities are chosen explicitly because their entity_id derives from the user name, not the id.)
  4. Trigger a full Sync MQTT in the app. The retained history topic fires the automation, which imports each series. Re-running is safe (idempotent per hourly bucket).

Custom openScale metrics are present in the payload but are not imported here (no matching HA entity); they remain available to other MQTT consumers. For full-fidelity historical graphs without HA statistics, the InfluxDB backend stores every metric with its real timestamp and pairs well with HA's InfluxDB integration or Grafana.

Blueprint (openscale_history_import.yaml)

blueprint:
  name: openScale Sync – import measurement history (long-term statistics)
  description: >
    Backfills Home Assistant long-term statistics from the retained
    `openScaleSync/<userId>/measurements/history` topic that openScale Sync publishes on a full
    sync. This makes an openScale user's HISTORICAL weight / body-fat / water / muscle appear in
    Home Assistant's history & statistics cards with their real timestamps.

    Requires the Spook integration (https://spook.boo) for the `recorder.import_statistics` service,
    the openScale Sync MQTT backend with Home Assistant discovery enabled, and one automation
    instance per openScale user (pick that user's id + sensors below).
  domain: automation
  input:
    user_id:
      name: openScale user id
      description: The numeric openScale userId this instance handles (the topic path segment).
      selector:
        text: {}
    weight_entity:
      name: Weight sensor
      description: The HA weight sensor for this user (leave empty to skip).
      default: ""
      selector:
        entity:
          domain: sensor
    fat_entity:
      name: Body-fat sensor
      default: ""
      selector:
        entity:
          domain: sensor
    water_entity:
      name: Water sensor
      default: ""
      selector:
        entity:
          domain: sensor
    muscle_entity:
      name: Muscle sensor
      default: ""
      selector:
        entity:
          domain: sensor

mode: queued
max: 10

variables:
  user_id: !input user_id
  metrics:
    - { column: "weight",   entity: !input weight_entity, unit: "kg" }
    - { column: "body_fat", entity: !input fat_entity,    unit: "%" }
    - { column: "water",    entity: !input water_entity,  unit: "%" }
    - { column: "muscle",   entity: !input muscle_entity, unit: "%" }

trigger:
  - platform: mqtt
    topic: openScaleSync/+/measurements/history

condition:
  - "{{ trigger.topic.split('/')[1] == (user_id | string) }}"
  - "{{ trigger.payload_json.fields is defined and trigger.payload_json.rows is defined }}"

action:
  - repeat:
      for_each: "{{ metrics }}"
      sequence:
        - variables:
            entity: "{{ repeat.item.entity }}"
            column: "{{ repeat.item.column }}"
            idx: >
              {{ trigger.payload_json.fields.index(column)
                 if column in trigger.payload_json.fields else -1 }}
        - condition: "{{ entity != '' and idx >= 0 }}"
        - service: recorder.import_statistics
          data:
            statistic_id: "{{ entity }}"
            source: recorder
            has_mean: true
            has_sum: false
            unit_of_measurement: "{{ repeat.item.unit }}"
            stats: >
              {% set ns = namespace(buckets={}) %}
              {% for row in trigger.payload_json.rows %}
                {% set v = row[idx] %}
                {% if v is not none %}
                  {% set start = (row[0] / 1000) | timestamp_custom('%Y-%m-%dT%H:00:00+00:00', false) %}
                  {% set ns.buckets = dict(ns.buckets, **{start: v}) %}
                {% endif %}
              {% endfor %}
              {% set out = namespace(rows=[]) %}
              {% for start, v in ns.buckets | dictsort %}
                {% set out.rows = out.rows + [{'start': start, 'mean': v, 'min': v, 'max': v}] %}
              {% endfor %}
              {{ out.rows }}