Skip to content

Commit caebdb0

Browse files
authored
Support for Etekcity ESF-551 (#1335)
* Support for Etekcity ESF-551 * Enabled unit configuration * Moved EPS * Removed some TODOs
1 parent ba44178 commit caebdb0

4 files changed

Lines changed: 350 additions & 0 deletions

File tree

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
@@ -31,6 +31,7 @@ import com.health.openscale.core.bluetooth.scales.ESCS20mHandler
3131
import com.health.openscale.core.bluetooth.scales.ExcelvanCF36xHandler
3232
import com.health.openscale.core.bluetooth.scales.ExingtechY1Handler
3333
import com.health.openscale.core.bluetooth.scales.EbelterBodyFatB2Handler
34+
import com.health.openscale.core.bluetooth.scales.EtekcityESF551Handler
3435
import com.health.openscale.core.bluetooth.scales.GattScaleAdapter
3536
import com.health.openscale.core.bluetooth.scales.HesleyHandler
3637
import com.health.openscale.core.bluetooth.scales.HoffenBbs8107Handler
@@ -112,6 +113,7 @@ class ScaleFactory @Inject constructor(
112113
ExingtechY1Handler(),
113114
EbelterBodyFatB2Handler(),
114115
ExcelvanCF36xHandler(),
116+
EtekcityESF551Handler(),
115117
ESCS20mHandler(),
116118
RenphoES26BBHandler(),
117119
DigooDGSO38HHandler(),
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package com.health.openscale.core.bluetooth.libs
2+
3+
import com.health.openscale.core.data.GenderType
4+
import kotlin.math.floor
5+
6+
// Based on https://github.com/ronnnnnnnnnnnnn/etekcity_esf551_ble
7+
8+
data class EtekcityLib(
9+
val gender: GenderType,
10+
val age: Int,
11+
val weightKg: Double,
12+
val heightM: Double,
13+
val impedance: Double,
14+
) {
15+
val bmi: Double = weightKg / (heightM * heightM)
16+
17+
val isMale = gender == GenderType.MALE
18+
19+
val bodyFatPercentage: Double by lazy {
20+
val ageFactor = if (isMale) 0.103 else 0.097
21+
val bmiFactor = if (isMale) 1.524 else 1.545
22+
val constant = if (isMale) 22.0 else 12.7
23+
val raw = floor((ageFactor * age + bmiFactor * bmi - 500.0 / impedance - constant) * 10) / 10.0
24+
raw.coerceIn(5.0, 75.0)
25+
}
26+
27+
val fatFreeWeight: Double = weightKg * (1 - bodyFatPercentage / 100)
28+
29+
val visceralFat: Double by lazy {
30+
val bmiFactor = if (isMale) 0.8666 else 0.8895
31+
val bfpFactor = if (isMale) 0.0082 else 0.0943
32+
val fatFactor = if (isMale) 0.026 else -0.0534
33+
val constant = if (isMale) 14.2692 else 16.215
34+
(bmiFactor * bmi + bfpFactor * bodyFatPercentage + fatFactor * (weightKg - fatFreeWeight) - constant)
35+
.coerceIn(1.0, 30.0)
36+
}
37+
38+
val water: Double by lazy {
39+
val ff1Factor = if (isMale) 0.05 else 0.06
40+
val ff2Factor = if (isMale) 0.76 else 0.73
41+
val ff1 = maxOf(1.0, ff1Factor * fatFreeWeight)
42+
((ff2Factor * (fatFreeWeight - ff1) / weightKg * 100.0)).coerceIn(10.0, 80.0)
43+
}
44+
45+
val basalMetabolicRate: Double = (fatFreeWeight * 21.6 + 370).coerceIn(900.0, 2500.0)
46+
47+
val skeletalMusclePercentage: Double by lazy {
48+
val ff1Factor = if (isMale) 0.05 else 0.06
49+
val ff2Factor = if (isMale) 0.68 else 0.62
50+
val ff1 = maxOf(1.0, ff1Factor * fatFreeWeight)
51+
ff2Factor * (fatFreeWeight - ff1) / weightKg * 100.0
52+
}
53+
54+
val boneMass: Double by lazy {
55+
val ff1Factor = if (isMale) 0.05 else 0.06
56+
maxOf(1.0, ff1Factor * fatFreeWeight)
57+
}
58+
59+
val subcutaneousFat: Double by lazy {
60+
val bfpFactor = if (isMale) 0.965 else 0.983
61+
val vfvFactor = if (isMale) 0.22 else 0.303
62+
bfpFactor * bodyFatPercentage - vfvFactor * visceralFat
63+
}
64+
65+
val muscleMass: Double by lazy {
66+
weightKg - boneMass - 0.01 * bodyFatPercentage * weightKg
67+
}
68+
69+
val proteinPercentage: Double by lazy {
70+
val bfpFactor = if (isMale) 1.0 else 1.05
71+
maxOf(5.0, 100 - bfpFactor * bodyFatPercentage - boneMass / weightKg * 100 - water)
72+
}
73+
74+
val weightScore: Int by lazy {
75+
val heightFactor = if (isMale) 100 else 137
76+
val constant = if (isMale) 80 else 110
77+
val factor = if (isMale) 0.7 else 0.45
78+
val res = factor * (heightFactor * heightM - constant)
79+
80+
if (res <= weightKg) {
81+
if (1.3 * res < weightKg) {
82+
return@lazy 50
83+
}
84+
return@lazy (100 - 50 * (weightKg - res) / (0.3 * res)).toInt()
85+
}
86+
if (res * 0.7 < weightKg) {
87+
return@lazy (100 - 50 * (res - weightKg) / (0.3 * res)).toInt()
88+
}
89+
for (x in 0..<6) {
90+
if (res * x / 10 > weightKg) {
91+
return@lazy x * 10
92+
}
93+
}
94+
0
95+
}
96+
97+
val fatScore: Int by lazy {
98+
val constant = if (isMale) 16 else 26
99+
if (constant < bodyFatPercentage) {
100+
if (bodyFatPercentage >= 45) {
101+
50
102+
} else {
103+
(100 - 50 * (bodyFatPercentage - constant) / (45 - constant)).toInt()
104+
}
105+
} else {
106+
(100 - 50 * (constant - bodyFatPercentage) / (constant - 5)).toInt()
107+
}
108+
}
109+
110+
val bmiScore: Int = when {
111+
bmi >= 35 -> 50
112+
bmi >= 22 -> (100 - 3.85 * (bmi - 22)).toInt()
113+
bmi >= 15 -> (100 - 3.85 * (22 - bmi)).toInt()
114+
bmi >= 10 -> 40
115+
bmi >= 5 -> 30
116+
else -> 20
117+
}
118+
119+
val healthScore: Int = (weightScore + fatScore + bmiScore) / 3
120+
121+
val metabolicAge: Int by lazy {
122+
val ageAdjustmentFactor = when {
123+
healthScore < 50 -> 0
124+
healthScore < 60 -> 1
125+
healthScore < 65 -> 2
126+
healthScore < 68 -> 3
127+
healthScore < 70 -> 4
128+
healthScore < 73 -> 5
129+
healthScore < 75 -> 6
130+
healthScore < 80 -> 7
131+
healthScore < 85 -> 8
132+
healthScore < 88 -> 9
133+
healthScore < 90 -> 10
134+
healthScore < 93 -> 11
135+
healthScore < 95 -> 12
136+
healthScore < 97 -> 13
137+
healthScore < 98 -> 14
138+
healthScore < 99 -> 15
139+
else -> 16
140+
}
141+
maxOf(18, age + 8 - ageAdjustmentFactor)
142+
}
143+
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package com.health.openscale.core.bluetooth.scales
2+
3+
import com.health.openscale.R
4+
import com.health.openscale.core.bluetooth.data.ScaleMeasurement
5+
import com.health.openscale.core.bluetooth.data.ScaleUser
6+
import com.health.openscale.core.bluetooth.libs.EtekcityLib
7+
import com.health.openscale.core.data.WeightUnit
8+
import com.health.openscale.core.service.ScannedDeviceInfo
9+
import java.util.Date
10+
import java.util.UUID
11+
12+
// Based on https://github.com/ronnnnnnnnnnnnn/etekcity_esf551_ble
13+
14+
/**
15+
* Etekcity ESF-551 scale handler
16+
*/
17+
class EtekcityESF551Handler : ScaleDeviceHandler() {
18+
19+
companion object {
20+
// TODO: Why can't uuid16 be used at the companion level? It's currently coupled to ScaleDeviceHandler.
21+
private val SCALE_SERVICE = UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb")
22+
private val WEIGHT_CHARACTERISTIC_NOTIFY = UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb")
23+
private val ALIRO_CHARACTERISTIC = UUID.fromString("0000fff2-0000-1000-8000-00805f9b34fb")
24+
25+
// TODO: These services and characteristics probably should be defined globally for all handlers.
26+
private val DEVICE_INFORMATION_SERVICE = UUID.fromString("0000180a-0000-1000-8000-00805f9b34fb")
27+
private val HW_REVISION_STRING_CHARACTERISTIC = UUID.fromString("00002a27-0000-1000-8000-00805f9b34fb")
28+
private val SW_REVISION_STRING_CHARACTERISTIC = UUID.fromString("00002a28-0000-1000-8000-00805f9b34fb")
29+
}
30+
31+
override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? {
32+
if (!device.name.startsWith("Etekcity Smart Fitness Scale", ignoreCase = true)) {
33+
return null
34+
}
35+
36+
return DeviceSupport(
37+
displayName = device.name,
38+
capabilities = setOf(
39+
DeviceCapability.LIVE_WEIGHT_STREAM,
40+
DeviceCapability.BODY_COMPOSITION,
41+
DeviceCapability.UNIT_CONFIG
42+
),
43+
implemented = setOf(
44+
DeviceCapability.LIVE_WEIGHT_STREAM,
45+
DeviceCapability.BODY_COMPOSITION,
46+
DeviceCapability.UNIT_CONFIG
47+
),
48+
linkMode = LinkMode.CONNECT_GATT
49+
)
50+
}
51+
52+
override fun onConnected(user: ScaleUser) {
53+
logI("ESF-551 connected, starting setup sequence")
54+
55+
setUnit(user.scaleUnit)
56+
57+
setNotifyOn(SCALE_SERVICE, WEIGHT_CHARACTERISTIC_NOTIFY)
58+
logD("Enabled notifications on weight characteristic")
59+
60+
userInfo(R.string.bt_info_waiting_for_measurement)
61+
}
62+
63+
override fun onDisconnected() {
64+
logI("ESF-551 disconnected")
65+
}
66+
67+
override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) {
68+
if (data.isEmpty()) return
69+
70+
if (characteristic == WEIGHT_CHARACTERISTIC_NOTIFY) {
71+
val measurement = parsePayload(data, user)
72+
if (measurement != null) {
73+
publish(measurement)
74+
requestDisconnect()
75+
}
76+
return
77+
}
78+
79+
logD("Notify $characteristic len: ${data.size} data: ${data.toHexPreview(32)}")
80+
}
81+
82+
private fun parsePayload(data: ByteArray, user: ScaleUser): ScaleMeasurement? {
83+
if (data.size != 22 ||
84+
data[0] != 0xa5.toByte() ||
85+
data[1] != 0x02.toByte() ||
86+
data[3] != 0x10.toByte() ||
87+
data[4] != 0x00.toByte() ||
88+
data[6] != 0x01.toByte() ||
89+
data[7] != 0x61.toByte() ||
90+
data[8] != 0xa1.toByte() ||
91+
data[9] != 0x00.toByte()
92+
) {
93+
logD("Invalid frame: len: ${data.size} data: ${data.toHexPreview(32)}")
94+
return null
95+
}
96+
97+
val weightRaw = data[10].toUInt() or data[11].toUInt().shl(8) or data[12].toUInt().shl(16)
98+
val weightKg = weightRaw.toInt() / 1000.0
99+
val impedance = (data[13].toUInt() or data[14].toUInt().shl(8)).toDouble()
100+
// val displayUnit = WeightUnit.fromInt(data[21].toInt())
101+
val measurement = ScaleMeasurement(
102+
userId = user.id,
103+
dateTime = Date(),
104+
weight = weightKg.toFloat(),
105+
impedance = impedance,
106+
)
107+
108+
if (impedance > 0) {
109+
val lib = EtekcityLib(
110+
gender = user.gender,
111+
age = user.age,
112+
weightKg = weightKg,
113+
heightM = user.bodyHeight / 100.0,
114+
impedance = impedance,
115+
)
116+
measurement.fat = lib.bodyFatPercentage.toFloat()
117+
measurement.water = lib.water.toFloat()
118+
measurement.muscle = lib.skeletalMusclePercentage.toFloat()
119+
measurement.visceralFat = lib.visceralFat.toFloat()
120+
measurement.bone = lib.boneMass.toFloat()
121+
measurement.bmr = lib.basalMetabolicRate.toFloat()
122+
123+
// TODO: Add other measurements once supported
124+
// measurement.fatFreeWeight = lib.fatFreeWeight.toFloat()
125+
// measurement.subcutaneousFat = lib.subcutaneousFat.toFloat()
126+
// measurement.muscleMass = lib.muscleMass.toFloat()
127+
// measurement.proteinPercentage = lib.proteinPercentage.toFloat()
128+
// measurement.weightScore = lib.weightScore
129+
// measurement.fatScore = lib.fatScore
130+
// measurement.bmiScore = lib.bmiScore
131+
// measurement.healthScore = lib.healthScore
132+
// measurement.metabolicAge = lib.metabolicAge
133+
}
134+
135+
if (data[20] == 1.toByte() && impedance > 0) {
136+
logD("Final measurement: $measurement")
137+
return measurement
138+
}
139+
140+
return null
141+
}
142+
143+
private fun setUnit(unit: WeightUnit) {
144+
val unitByte = unit.toInt().toByte()
145+
val cmd = byteArrayOf(
146+
0xa5.toByte(),
147+
0x22,
148+
0x03,
149+
0x05,
150+
0x00,
151+
(43 - unitByte).toByte(),
152+
0x01,
153+
0x63,
154+
0xa1.toByte(),
155+
0x00,
156+
unitByte
157+
)
158+
writeTo(SCALE_SERVICE, ALIRO_CHARACTERISTIC, cmd, withResponse = false)
159+
logD("Unit update command sent: $unit")
160+
}
161+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.health.openscale.core.bluetooth.libs
2+
3+
import com.google.common.truth.Truth.assertThat
4+
import com.health.openscale.core.data.GenderType
5+
import org.junit.Test
6+
7+
/**
8+
* Unit tests for [EtekcityLib].
9+
*/
10+
class EtekcityLibTest {
11+
internal val EPS = 1e-3 // general float tolerance
12+
13+
val lib = EtekcityLib(gender = GenderType.MALE, age = 30, weightKg = 70.0, heightM = 1.8, impedance = 527.0)
14+
15+
@Test
16+
fun bmi_isComputedCorrectly_forTypicalMale() {
17+
assertThat(lib.bmi).isWithin(EPS).of(21.6049)
18+
assertThat(lib.bodyFatPercentage).isWithin(EPS).of(13.0)
19+
assertThat(lib.fatFreeWeight).isWithin(EPS).of(60.9)
20+
assertThat(lib.visceralFat).isWithin(EPS).of(4.7968)
21+
assertThat(lib.water).isWithin(EPS).of(62.814)
22+
assertThat(lib.basalMetabolicRate).isWithin(EPS).of(1685.44)
23+
assertThat(lib.skeletalMusclePercentage).isWithin(EPS).of(56.202)
24+
assertThat(lib.boneMass).isWithin(EPS).of(3.045)
25+
assertThat(lib.subcutaneousFat).isWithin(EPS).of(11.4897)
26+
assertThat(lib.muscleMass).isWithin(EPS).of(57.855)
27+
assertThat(lib.proteinPercentage).isWithin(EPS).of(19.836)
28+
assertThat(lib.weightScore).isEqualTo(100)
29+
assertThat(lib.fatScore).isEqualTo(86)
30+
assertThat(lib.bmiScore).isEqualTo(98)
31+
assertThat(lib.healthScore).isEqualTo(94)
32+
assertThat(lib.metabolicAge).isEqualTo(26)
33+
}
34+
35+
@Test
36+
fun bmi_monotonicity_weightUp_heightSame_increases() {
37+
assertThat(lib.run { copy(weightKg = weightKg + 5.0) }.bmi).isGreaterThan(lib.bmi)
38+
}
39+
40+
@Test
41+
fun bmi_monotonicity_heightUp_weightSame_decreases() {
42+
assertThat(lib.run { copy(heightM = heightM + 0.05) }.bmi).isLessThan(lib.bmi)
43+
}
44+
}

0 commit comments

Comments
 (0)