-
-
Notifications
You must be signed in to change notification settings - Fork 486
How to support a new scale
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).
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.
-
Discover the Bluetooth services and characteristics
-
Enable file logging in the openScale app: go to
Settings > Generaland enable theFile loggingoption. -
Add the scale as a debug device: go to
Settings > Bluetoothand find your scale in the list. Tap the three-dot menu to its right and selectSave as debug device. This assigns the genericDebug GATThandler to it. -
Capture the services and characteristics: return to the openScale
Overviewscreen and tap the Bluetooth icon in the top bar to initiate a connection to your debug device. -
Export the log: go back to
Settings > Generaland tap theExport log filebutton. This will create a shareable.txtfile. - Open this log file. It will contain a list of all discovered services and characteristics.
-
Enable file logging in the openScale app: go to
-
Enable Bluetooth HCI Snoop Log
- In Android Developer Options, enable Bluetooth HCI snoop log.
- Toggle Bluetooth off and on to start logging.
-
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).
-
Extract the Log File
- Disable the HCI snoop log.
- The
btsnoop_hci.logfile is usually located in/data/misc/bluetooth/logs. - Use
adb bugreportto extract it as a zip file.
-
Analyze the Log
- Open the
.logfile 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.
- Open the
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
Writerequests. 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 NotificationorIndicationpackets. Analyze the byte payload structure to decode weight, impedance, and other values.
Once you have decoded the protocol, you can implement the handler. ScaleDeviceHandler encapsulates all device-specific logic.
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),ScaleCommunicatorand everything else undercore/bluetooth/outsidescales/andlibs/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.
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
}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 useCONNECT_GATT.
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)
}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()
}
}
}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.
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.
ScaleFactoryTestruns 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.
ScaleCatalogTestasks the winning handler for itsDeviceSupportand generates the wiki page Overview of supported scales from it. CI regenerates and pushes that page on every merge tomaster, 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 testCompile and run the app on your Android device.
- Go to
Settings > Bluetoothwithin openScale. - Tap the search button to scan for devices.
- If your
supportForimplementation is correct, your scale should appear in the list with thedisplayNameyou specified. - Select it, attempt to connect, and step on the scale.
- 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.
Once your handler is working reliably, sharing it with the openScale community is a fantastic way to give back.
- Fork the Repository: Create your own fork of the official openScale repository on GitHub.
-
Create a Branch: In your fork, create a new branch for your changes (e.g.,
feat/add-my-awesome-scale-handler). -
Commit Your Changes: Commit the new
MyNewScaleHandler.ktfile and the modifications toScaleFactory.kt. Write a clear commit message, likefeat: Add support for My Awesome Scale. -
Open a Pull Request (PR): Push your branch to your fork and open a new Pull Request against the
masterbranch of theoliexdev/openScalerepository. -
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.logfile 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.
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.