Skip to content

How to support a new scale

oliexdev edited this page Aug 16, 2026 · 5 revisions

Adding support for a new Bluetooth scale in openScale is a two-part process: first, you need to reverse-engineer the scale's communication protocol, and then implement a new handler within the app to manage this protocol. This guide will walk you through creating a new ScaleDeviceHandler in openScale's modern Kotlin-based architecture.

Note

Prerequisites: A foundational understanding of Android development with Kotlin and basics of Bluetooth Low Energy (GATT).


Part 1: Analyzing the Bluetooth Protocol

Before writing code, you must understand how the scale communicates. The key is to capture the Bluetooth traffic between the scale and its official manufacturer's app. For a general introduction to Bluetooth reverse engineering, see the Gadgetbridge Bluetooth Development Guide.

  1. Discover the Bluetooth services and characteristics

    • Enable file logging in the openScale app: go to Settings > General and enable the File logging option.
    • Add the scale as a debug device: go to Settings > Bluetooth and find your scale in the list. Tap the three-dot menu to its right and select Save as debug device. This assigns the generic Debug GATT handler to it.
    • Capture the services and characteristics: return to the openScale Overview screen and tap the Bluetooth icon in the top bar to initiate a connection to your debug device.
    • Export the log: go back to Settings > General and tap the Export log file button. This will create a shareable .txt file.
    • Open this log file. It will contain a list of all discovered services and characteristics.
  2. Enable Bluetooth HCI Snoop Log

    • In Android Developer Options, enable Bluetooth HCI snoop log.
    • Toggle Bluetooth off and on to start logging.
  3. Capture Traffic

    • Use the manufacturer's app to weigh yourself multiple times.
    • Record the exact weight and any other metrics (e.g., body fat, water percentage) along with timestamps.
    • Repeat this for several different measurements to capture varied data, along with the user information (age, activity level, height and so on).
  4. Extract the Log File

    • Disable the HCI snoop log.
    • The btsnoop_hci.log file is usually located in /data/misc/bluetooth/logs.
    • Use adb bugreport to extract it as a zip file.
  5. Analyze the Log

    • Open the .log file with Wireshark.
    • Look for relevant Bluetooth packets:
      • Search for recorded weight values (often hexadecimal, sometimes scaled by 10 or 100, e.g., 75.5 kg → 7550).
      • Focus on Write and Indication/Notification packets.

Questions to answer:

  • What are the key Service and Characteristic UUIDs? You'll need them for reading, writing, and subscribing to notifications.
  • What commands does the app send to the scale? Look for Write requests. What do their byte payloads mean (start measurement, sync user profile)?
  • In what format does the scale send its data? Look at the Handle Value Notification or Indication packets. Analyze the byte payload structure to decode weight, impedance, and other values.

Part 2: Implementing the ScaleDeviceHandler

Once you have decoded the protocol, you can implement the handler. ScaleDeviceHandler encapsulates all device-specific logic.

0. Ground Rules

Please read these before you start writing code. They are what keeps ~40 drivers maintainable side by side, and they are the points that come up most often in review.

Everything you add belongs to your scale. A new device should be a new handler file, optionally one library file, one line in ScaleFactory, one line in ScaleCatalog, and your tests. Nothing else.

  • One library file per handler, not a package. Put protocol parsing, decryption and body-composition math in a single app/src/main/java/com/health/openscale/core/bluetooth/libs/MyScaleLib.kt — not split across several files or a sub-package. Keep it free of Android dependencies so it can be unit-tested on the JVM, and leave the handler as the thin layer that moves bytes.
  • Do not change the shared Bluetooth infrastructure. ScaleDeviceHandler, the adapters (GattScaleAdapter, BroadcastScaleAdapter, SppScaleAdapter), ScaleCommunicator and everything else under core/bluetooth/ outside scales/ and libs/ is shared by every scale in the app — a change there can break devices nobody in the PR owns. If your scale genuinely needs something the base class cannot do, open an issue first and describe what the protocol requires and why the existing helpers are not enough. A pull request that quietly modifies the base class will be asked to split that part out.
  • Only decode what you have actually seen. Decode a field only if a capture proves what it means. Fields you guessed at are written into people's measurement history, and plausibility checks do not save you — an unknown byte pair will pass a range check often enough to look right.
  • Do not compute what openScale already derives. BMI, BMR, lean body mass and similar values come from the app's own derived-value system. Publish what the scale really sends — weight, fat, impedance, heart rate — and leave the rest at zero.
  • Secrets stay with the user. If the scale needs a vendor key, offer an input for it via DeviceConfigurationUi() and store it in the per-driver settings. Do not add key-extraction tooling and never commit keys, MAC addresses or personal captures.

The usual project conventions apply on top of that: the GPLv3 header on every new file, logging through the handler's logD/logI/logW helpers (not android.util.Log), and user-facing text in res/values/strings.xml.

1. Create a New Handler File

In app/src/main/java/com/health/openscale/core/bluetooth/scales/, create a Kotlin file, e.g., MyNewScaleHandler.kt.

package com.health.openscale.core.bluetooth.scales

import com.health.openscale.core.bluetooth.data.ScaleMeasurement
import com.health.openscale.core.bluetooth.data.ScaleUser
import com.health.openscale.core.service.ScannedDeviceInfo
import java.util.Date
import java.util.UUID

class MyNewScaleHandler : ScaleDeviceHandler() {
    // Implementation will go here
}

2. Implement Device Discovery (supportFor)

The first and most crucial method is supportFor. This is called for every scanned Bluetooth device to check if your handler can support it. The decision is usually based on the device's advertised name or the service UUIDs it broadcasts.

override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? {
    // Check based on the advertised name or service UUIDs.
    // Using uppercase is a robust way to handle device name variations.
    val name = device.name.uppercase()

    if (name.startsWith("MY_SCALE_BT_NAME")) {
        // This handler supports the device. Return a description.
        return DeviceSupport(
            displayName = "My Awesome Scale",
            capabilities = setOf(
                DeviceCapability.LIVE_WEIGHT_STREAM,
                DeviceCapability.BODY_COMPOSITION,
                DeviceCapability.HISTORY_READ
            ),
            implemented = setOf( // Be honest about what you have implemented so far.
                DeviceCapability.LIVE_WEIGHT_STREAM
            ),
            linkMode = LinkMode.CONNECT_GATT // Or BROADCAST_ONLY, CLASSIC_SPP
        )
    }

    // This handler does not support the device.
    return null
}

Notes:

  • displayName: The user-friendly name shown in the app's device list.
  • capabilities: A Set of all features the scale hardware theoretically supports.
  • implemented: A subset of capabilities that your handler actually implements. This helps the UI show what's working.
  • linkMode: Defines the communication type. Most modern scales use CONNECT_GATT.

3. Implement Connection Logic (onConnected)

This method is called after a GATT connection is successfully established. Use it to send any initialization commands you identified in Part 1, such as setting the time, syncing the user, or—most commonly—enabling notifications on the characteristic that will deliver measurement data.

override fun onConnected(user: ScaleUser) {
    logI("Starting connection sequence for MyNewScale.")

    // Example: Enable notifications on the measurement characteristic.
    // Replace these UUIDs with the ones you found during your analysis.
    val serviceUUID = uuid16(0x181B) // e.g., Body Composition Service
    val measurementCharUUID = uuid16(0x2A9C) // e.g., Weight Measurement

    setNotifyOn(serviceUUID, measurementCharUUID)

    // Example: Send a command to the scale to prepare it for a measurement.
    // This is highly device-specific.
    val startCommand = byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte())
    val commandCharUUID = uuid16(0x1542) // A custom command characteristic

    writeTo(serviceUUID, commandCharUUID, startCommand)

    // Inform the user about the next step.
    userInfo(R.string.bt_info_waiting_for_measurement)
}

4. Implement Data Parsing (onNotification)

When the scale sends data (typically via GATT notifications), this method is invoked. Your job is to parse the data byte array according to the protocol you reverse-engineered and convert it into a ScaleMeasurement.

override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) {
    val measurementCharUUID = uuid16(0x2A9C) // Same UUID as in onConnected

    if (characteristic == measurementCharUUID && data.isNotEmpty()) {
        logD("Measurement data received: ${data.toHexPreview(24)}")

        // -- This parsing logic is completely specific to your scale --
        // Assumption: Weight is a 16-bit little-endian value at byte 2, scaled by 100.
        // Consult the openScale source (e.g., RenphoES26BBHandler) for parsing examples.
        val weightRaw = (data[3].toInt() and 0xFF shl 8) or (data[2].toInt() and 0xFF)
        val weight = weightRaw / 100.0f

        // Create a ScaleMeasurement object. The weight must always be in kg.
        // If the scale sends a timestamp of its own, decode it and use it here;
        // otherwise fall back to the current time.
        val measurement = ScaleMeasurement()
        measurement.weight = weight
        measurement.dateTime = Date()

        // The scale might send multiple "live" weight updates.
        // You need to identify when the measurement is final or "stable".
        // This flag is often a bit in the first byte of the payload.
        val isStable = (data[0].toInt() and 0x01) == 1

        if (isStable) {
            logI("Stable measurement received, publishing to app.")
            publish(measurement)

            // Optional: Disconnect after a successful measurement to save battery.
            requestDisconnect()
        }
    }
}

5. Register the Handler

To make openScale aware of your new handler, you must add it to the list of known handlers in ScaleFactory.kt.

Open app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt and add an instance of your new handler to the list returned by createHandlers(). It's good practice to add it near the top.

// In ScaleFactory.kt

@VisibleForTesting
internal fun createHandlers(): List<ScaleDeviceHandler> = listOf(
    MyNewScaleHandler(), // Add your handler here
    RenphoES26BBHandler(),
    YunmaiHandler(isMini = false),
    // ... other handlers
)

Important

The order matters: ScaleFactory picks the first handler whose supportFor returns a non-null result. A matcher that is too broad (for example one that matches on a very short name prefix) will steal devices from handlers further down the list.

6. Add Your Scale to the Catalog

This is the last required step, and it is a single line. Open app/src/test/java/com/health/openscale/core/bluetooth/ScaleCatalog.kt and add your scale to the fixtures list: a synthetic advertisement your supportFor recognises, plus the handler that has to claim it.

// In ScaleCatalog.kt, in the fixtures list

device("MY_SCALE_BT_NAME") claimedBy MyNewScaleHandler::class.java,

If your scale advertises no name and is recognised by its manufacturer data or service UUIDs instead, describe it the same way you match it:

advertisement(
    services = listOf(uuid16(0xFFD0)),
    manufacturerData = listOf(0x06D0 to ByteArray(20)),
) claimedBy MyNewScaleHandler::class.java,

That one line does three jobs:

  • It proves your handler is reachable. ScaleFactoryTest runs every fixture through the real registry and asserts that your handler wins — this is what catches an existing driver swallowing your device, or your matcher stealing someone else's.
  • It publishes your scale. ScaleCatalogTest asks the winning handler for its DeviceSupport and generates the wiki page Overview of supported scales from it. CI regenerates and pushes that page on every merge to master, so your scale appears in the table without anyone editing the wiki.
  • It keeps the table honest. A registered handler without a fixture fails the build, which is what stopped the old hand-written table from drifting behind the code.

Add one fixture per product your handler reports under a different display name (the Beurer, Medisana and Omron drivers each cover several models), because each display name becomes its own row.

Note

Everything else in the published table — display name, connection type, capabilities — is read from your supportFor, so there is nothing else to write down. Only a free-text note for the "Remarks" column is maintained by hand, in app/src/test/resources/scale_catalog_remarks.txt.

Beyond that one fixture, add a unit test in app/src/test/ for your library file — feed it a payload you captured (with the byte layout written down in a comment) and assert the values it decodes. This is the part reviewers can actually check, because nobody else owns your scale.

Use Truth assertions (assertThat(...)) like the rest of the suite rather than org.junit.Assert.*, and only pull in Robolectric if your code touches Android classes.

Then run the tests:

./gradlew test

7. Compile and Test

Compile and run the app on your Android device.

  1. Go to Settings > Bluetooth within openScale.
  2. Tap the search button to scan for devices.
  3. If your supportFor implementation is correct, your scale should appear in the list with the displayName you specified.
  4. Select it, attempt to connect, and step on the scale.
  5. Use Logcat in Android Studio to monitor the log messages (filtered by your handler's TAG), or use the in-app logging under Settings > General > File logging. This is essential for debugging your connection and parsing logic.

8. Contributing Your Handler

Once your handler is working reliably, sharing it with the openScale community is a fantastic way to give back.

  1. Fork the Repository: Create your own fork of the official openScale repository on GitHub.
  2. Create a Branch: In your fork, create a new branch for your changes (e.g., feat/add-my-awesome-scale-handler).
  3. Commit Your Changes: Commit the new MyNewScaleHandler.kt file and the modifications to ScaleFactory.kt. Write a clear commit message, like feat: Add support for My Awesome Scale.
  4. Open a Pull Request (PR): Push your branch to your fork and open a new Pull Request against the master branch of the oliexdev/openScale repository.
  5. Describe Your PR: In the Pull Request description, provide the following information:
    • The exact model name of the scale you added.
    • A brief summary of what features are working (e.g., "Live weight and historical data import work. Body composition is not yet implemented.").
    • If possible, attach the btsnoop_hci.log file you captured. This is incredibly valuable for the maintainers to verify the protocol and assist with debugging.

By contributing your handler, you help make openScale better for everyone.

Conclusion

Adding a new scale handler is a methodical process. The reverse-engineering phase is often the most challenging. Once you understand the protocol, the ScaleDeviceHandler architecture in openScale provides a clean and robust framework for sending commands, receiving data, and integrating the device's functionality into the rest of the application.

Clone this wiki locally