Skip to content

MQTT support

OliE edited this page Oct 17, 2026 · 10 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 (broker.hivemq.com, port 8883 with SSL/TLS or 1883 without). Anyone can read and write every topic on a public broker — use test data only, and set a custom MQTT prefix so your topics do not mix with other users'.

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, body water, muscle mass, lean body mass and bone mass are in percent (%) of the weight. Conversions to other units must be handled by the respective program.

Topic structure (multi-user)

Every topic is per user — the stable openScale userId is a 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.

With a custom MQTT prefix set, the prefix is an extra level after openScaleSync:

openScaleSync/<prefix>/<userId>/measurements/<event>

All topics below are written without a prefix; with one, insert it after openScaleSync/.

In addition to the convenience fields weight / body_fat / water / muscle / lbm / bone, each measurement payload carries a self-describing values array containing all metric types of the measurement — predefined ones, ones contributed by a scale, user-created ones and derived ones (isDerived: true, e.g. BMI). The convenience fields are derived from this array. A metric the measurement does not have is absent from both, never sent as 0 — so treat every field except weight as optional.

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,
  "username": "Alice",
  "values": [
    { "identity": "builtin.weight",   "name": "Weight",   "unit": "kg", "isDerived": false, "value": 80.5 },
    { "identity": "builtin.body_fat", "name": "Body fat", "unit": "%",  "isDerived": false, "value": 22.5 },
    { "identity": "builtin.bmi",      "name": "BMI",      "unit": "",   "isDerived": true,  "value": 26.3 },
    { "identity": "user.steps",       "name": "Steps",    "unit": "",   "isDerived": false, "value": 8500 }
  ]
}

This measurement has no water, muscle, lean body mass or bone value, so water, muscle, lbm and bone are left out.

The identity is the stable, namespaced identifier of a metric: builtin.* for openScale's predefined types, ble.* for types a scale contributes, user.* for types the user created. The unit is a UCUM code and the value is already in the canonical unit of its dimension (kg, cm, …). A text-type metric carries text instead of value.

Update a measurement

Published when an existing measurement is edited.

  • Topic: openScaleSync/<userId>/measurements/update
  • Payload: same format as insert. A value removed in openScale is absent from the update.

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 appears as an additional column, named after its identity without the namespace (builtin.waist → waist, user.steps → steps).

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

Importing measurements into openScale (inbound)

openScale Sync can also read measurements from the broker and write them into openScale, for example from another scale or an automation. This runs when MQTT's sync direction is Import or Both, on a manual sync (and on the periodic background sync, if enabled).

  • Topic: openScaleSync/inbound/<userId>/<anything> — publish it retained, so it is still there when openScale Sync reads it. The last level is free (e.g. the name of the source).
  • Payload:
{
  "date": 1735292400000,
  "weight": 80.5,
  "body_fat": 22.5,
  "water": 55.8
}

date (epoch milliseconds) and weight (kg) are required; a message without a weight above 0 is ignored. body_fat and water (%) are optional, and so is a values array in the format shown above. A userId field in the payload takes precedence over the one in the topic.

If openScale already has a measurement of that user at exactly that time, it is updated; otherwise a new one is created. Re-reading the same retained message is therefore harmless. To remove it from the broker, publish an empty retained message to the same topic.

Custom MQTT prefix (several phones on one broker)

openScale numbers its users from 1 on every phone. One phone with several users is fine — each user has its own id. But if several phones, each with its own openScale, send to the same broker, their users share the same ids — and therefore the same topics and the same Home Assistant devices: the sensors jump between people, one phone's history overwrites the other's, and inbound messages are imported on both phones.

Give each phone its own prefix to keep them apart:

  1. In openScale Sync, open MQTT and switch on Custom MQTT prefix.
  2. Enter a prefix, e.g. alice on Alice's phone and bob on Bob's. Letters, digits, _ and - are allowed (inbound is used as inbound_, so it cannot reach into the inbound topics).
  3. Run a manual Sync so the retained last and history topics are filled under the new prefix.
without prefix with prefix alice
Measurements openScaleSync/1/measurements/last openScaleSync/alice/1/measurements/last
Inbound openScaleSync/inbound/1/… openScaleSync/alice/inbound/1/…
HA discovery homeassistant/device/openscale_1/config homeassistant/device/openscale_alice_1/config
HA device openscale_1 openscale_alice_1

A changed prefix takes effect on the next sync. Nothing published under the previous topics is removed automatically, as another phone may still use them: delete the old device in Home Assistant (this also removes its retained discovery message), and clear the old retained topics with an MQTT client if you want them gone. Switching the prefix off keeps what you typed, so switching it back on restores it.

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. No manual configuration is needed, with or without a prefix.

The discovery configuration is published (retained) to:

homeassistant/device/openscale_<userId>/config

or, with a custom MQTT prefix, to homeassistant/device/openscale_<prefix>_<userId>/config.

Each user's device is named openScale (<username>) with identifier openscale_<userId> (openscale_<prefix>_<userId> with a prefix), and its entities' unique_ids carry the same suffix so Home Assistant keeps users — and phones — separate. The sensors read their state from that user's retained …/measurements/last topic (e.g. the body-fat sensor uses value_json.body_fat). A metric the latest measurement does not have shows as unknown, not 0. 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 history topic, e.g. openScaleSync/1/measurements/history — or openScaleSync/alice/1/measurements/history with the prefix alice — and pick that user's weight / body-fat / water / muscle sensors. Leave a sensor empty to skip that metric. The user id is the number at the end of the device's discovery identifier (openscale_1, openscale_alice_1), shown under MQTT INFO on the device page.
  4. Trigger a full Sync with 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
    `…/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 (enter that user's history topic + pick the sensors below).
  domain: automation
  input:
    history_topic:
      name: History topic
      description: >
        The user's retained history topic, e.g. openScaleSync/1/measurements/history, or
        openScaleSync/<prefix>/1/measurements/history with a custom MQTT prefix.
      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:
  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: !input history_topic

condition:
  - "{{ 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 }}