Skip to content

Commit 2c52f54

Browse files
babbittJoe
andauthored
add Runstar R5 (#1337)
Co-authored-by: Joe <code@joebabbitt.com>
1 parent cd2816f commit 2c52f54

File tree

2 files changed

+193
-0
lines changed

2 files changed

+193
-0
lines changed

android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import com.health.openscale.core.bluetooth.scales.QNHandler
5050
import com.health.openscale.core.bluetooth.scales.RealmeScaleHandler
5151
import com.health.openscale.core.bluetooth.scales.RenphoES26BBHandler
5252
import com.health.openscale.core.bluetooth.scales.RenphoHandler
53+
import com.health.openscale.core.bluetooth.scales.RunstarR5Handler
5354
import com.health.openscale.core.bluetooth.scales.SanitasSbf72Handler
5455
import com.health.openscale.core.bluetooth.scales.SenssunHandler
5556
import com.health.openscale.core.bluetooth.scales.SinocareHandler
@@ -103,6 +104,7 @@ class ScaleFactory @Inject constructor(
103104
OkOkHandler(),
104105
MiScaleS400Handler(),
105106
MiScaleHandler(),
107+
RunstarR5Handler(),
106108
MGBHandler(),
107109
MedisanaBs44xHandler(),
108110
InlifeHandler(),
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
/*
2+
* openScale
3+
* Copyright (C) 2026 olie.xdev <olie.xdeveloper@googlemail.com>
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
package com.health.openscale.core.bluetooth.scales
19+
20+
import com.health.openscale.R
21+
import com.health.openscale.core.bluetooth.data.ScaleMeasurement
22+
import com.health.openscale.core.bluetooth.data.ScaleUser
23+
import com.health.openscale.core.data.WeightUnit
24+
import com.health.openscale.core.service.ScannedDeviceInfo
25+
import java.util.Calendar
26+
import java.util.Date
27+
import java.util.Locale
28+
import java.util.UUID
29+
30+
/**
31+
* Runstar R5 handler.
32+
*
33+
* Protocol derived from the captured openScale session log:
34+
* - Service 0xFFB0
35+
* - Write characteristic 0xFFB1
36+
* - Notify characteristic 0xFFB2
37+
* - Initialization sequence matches the observed Runstar session exactly
38+
* - Notification frames are 20 bytes and carry a 24-bit big-endian weight value in bytes 6..8
39+
* - Stable measurements are flagged by mode 0x02 in byte 4
40+
* - Captured BLE traffic exposed weight only; body-composition values were not observed on the
41+
* accessible characteristics, so this handler intentionally implements weight-only support
42+
*
43+
* The scale appears to be related to other ICOMON-branded devices, but the payload layout
44+
* differs from the existing MGB handler, so it gets a dedicated implementation.
45+
*/
46+
class RunstarR5Handler : ScaleDeviceHandler() {
47+
companion object {
48+
private const val WEIGHT_RAW_BASE = 0x680000
49+
}
50+
51+
private val service = uuid16(0xFFB0)
52+
private val writeCharacteristic = uuid16(0xFFB1)
53+
private val notifyCharacteristic = uuid16(0xFFB2)
54+
55+
private var lastPublishedWeightRaw: Int? = null
56+
private var lastPreviewWeightKg = -1f
57+
58+
override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? {
59+
val name = device.name.uppercase(Locale.US)
60+
val matchesKnownAdvertisingName =
61+
name == "RUNSTAR-R5" || name == "RUNSTAR-RX" || name.startsWith("RUNSTAR-")
62+
if (!matchesKnownAdvertisingName) return null
63+
64+
return DeviceSupport(
65+
displayName = "Runstar R5",
66+
capabilities = setOf(
67+
DeviceCapability.LIVE_WEIGHT_STREAM,
68+
DeviceCapability.BODY_COMPOSITION,
69+
DeviceCapability.TIME_SYNC,
70+
DeviceCapability.USER_SYNC,
71+
DeviceCapability.UNIT_CONFIG
72+
),
73+
implemented = setOf(
74+
DeviceCapability.LIVE_WEIGHT_STREAM,
75+
DeviceCapability.TIME_SYNC,
76+
DeviceCapability.USER_SYNC,
77+
DeviceCapability.UNIT_CONFIG
78+
),
79+
linkMode = LinkMode.CONNECT_GATT
80+
)
81+
}
82+
83+
override fun onConnected(user: ScaleUser) {
84+
lastPublishedWeightRaw = null
85+
lastPreviewWeightKg = -1f
86+
setNotifyOn(service, notifyCharacteristic)
87+
88+
writeConfig(0xF7, 0x00, 0x00, 0x00)
89+
writeConfig(0xFA, 0x00, 0x00, 0x00)
90+
writeConfig(
91+
0xFB,
92+
if (user.gender.isMale()) 0x01 else 0x02,
93+
user.age.coerceAtLeast(0),
94+
user.bodyHeight.toInt().coerceAtLeast(0)
95+
)
96+
97+
val now = Calendar.getInstance()
98+
writeConfig(
99+
0xFD,
100+
(now.get(Calendar.YEAR) - 2000).coerceIn(0, 99),
101+
now.get(Calendar.MONTH) + 1,
102+
now.get(Calendar.DAY_OF_MONTH)
103+
)
104+
writeConfig(
105+
0xFC,
106+
now.get(Calendar.HOUR_OF_DAY),
107+
now.get(Calendar.MINUTE),
108+
now.get(Calendar.SECOND)
109+
)
110+
writeConfig(0xFE, 0x06, unitCode(user.scaleUnit), 0x00)
111+
112+
userInfo(R.string.bt_info_step_on_scale)
113+
}
114+
115+
override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) {
116+
if (characteristic != notifyCharacteristic) return
117+
if (data.size != 20) return
118+
if (data[3] != 0xA2.toByte()) {
119+
logD("Unhandled Runstar frame ${data.toHexPreview(20)}")
120+
return
121+
}
122+
123+
val mode = data[4].toInt() and 0xFF
124+
val rawWeight = u24be(data, 6)
125+
val weightKg = decodeWeightKg(rawWeight)
126+
127+
when (mode) {
128+
0x01 -> {
129+
if (kotlin.math.abs(weightKg - lastPreviewWeightKg) >= 0.05f) {
130+
userInfo(R.string.bluetooth_scale_info_measuring_weight, weightKg)
131+
lastPreviewWeightKg = weightKg
132+
}
133+
logD("Runstar measuring frame weight=$weightKg kg raw=$rawWeight")
134+
}
135+
136+
0x02 -> {
137+
if (lastPublishedWeightRaw == rawWeight) return
138+
139+
val measurement = ScaleMeasurement().apply {
140+
dateTime = Date()
141+
weight = weightKg
142+
}
143+
publish(measurement)
144+
lastPublishedWeightRaw = rawWeight
145+
logI("Runstar final weight=$weightKg kg raw=$rawWeight")
146+
requestDisconnect()
147+
}
148+
149+
else -> logD("Runstar unknown frame mode=$mode ${data.toHexPreview(20)}")
150+
}
151+
}
152+
153+
private fun writeConfig(b2: Int, b3: Int, b4: Int, b5: Int) {
154+
val payload = byteArrayOf(
155+
0xAC.toByte(),
156+
0x02,
157+
(b2 and 0xFF).toByte(),
158+
(b3 and 0xFF).toByte(),
159+
(b4 and 0xFF).toByte(),
160+
(b5 and 0xFF).toByte(),
161+
0xCC.toByte(),
162+
0x00
163+
)
164+
165+
val checksum =
166+
((payload[2].toInt() and 0xFF) +
167+
(payload[3].toInt() and 0xFF) +
168+
(payload[4].toInt() and 0xFF) +
169+
(payload[5].toInt() and 0xFF) +
170+
(payload[6].toInt() and 0xFF)) and 0xFF
171+
payload[7] = checksum.toByte()
172+
173+
writeTo(service, writeCharacteristic, payload, withResponse = true)
174+
}
175+
176+
private fun unitCode(unit: WeightUnit): Int = when (unit) {
177+
WeightUnit.KG -> 0x00
178+
WeightUnit.LB -> 0x01
179+
WeightUnit.ST -> 0x02
180+
}
181+
182+
private fun u24be(data: ByteArray, offset: Int): Int =
183+
((data[offset].toInt() and 0xFF) shl 16) or
184+
((data[offset + 1].toInt() and 0xFF) shl 8) or
185+
(data[offset + 2].toInt() and 0xFF)
186+
187+
private fun decodeWeightKg(rawWeight: Int): Float {
188+
val shifted = rawWeight - WEIGHT_RAW_BASE
189+
return shifted.coerceAtLeast(0) / 1000.0f
190+
}
191+
}

0 commit comments

Comments
 (0)