Skip to content

Commit f8bf51f

Browse files
authored
add rudimentary support for AE-BS06 (#1091)
1 parent f8f8669 commit f8bf51f

File tree

2 files changed

+329
-0
lines changed

2 files changed

+329
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
/* Copyright (C) 2024 olie.xdev <[email protected]>
2+
* 2024 Duncan Overbruck <[email protected]>
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <http://www.gnu.org/licenses/>
16+
*/
17+
18+
package com.health.openscale.core.bluetooth;
19+
20+
import android.content.Context;
21+
22+
import com.health.openscale.R;
23+
import com.health.openscale.core.OpenScale;
24+
import com.health.openscale.core.datatypes.ScaleMeasurement;
25+
import com.health.openscale.core.datatypes.ScaleUser;
26+
import com.health.openscale.core.utils.Converters;
27+
28+
import java.nio.ByteBuffer;
29+
import java.time.Instant;
30+
import java.util.ArrayList;
31+
import java.util.Arrays;
32+
import java.util.Calendar;
33+
import java.util.HashMap;
34+
import java.util.List;
35+
import java.util.Map;
36+
import java.util.UUID;
37+
38+
import timber.log.Timber;
39+
40+
/**
41+
* Support for Active Era BS-06 scales
42+
*
43+
* based on reverse-engineered BLE protocol known as `ICBleProtocolVerScaleNew2` from the vendor APP
44+
*/
45+
public class BluetoothActiveEraBF06 extends BluetoothCommunication {
46+
private static final byte MAGIC_BYTE = (byte) 0xAC;
47+
private static final byte DEVICE_TYPE = (byte) 0x27;
48+
49+
private final UUID MEASUREMENT_SERVICE = BluetoothGattUuid.fromShortCode(0xffb0);
50+
private final UUID WRITE_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xffb1);
51+
private final UUID NOTIFICATION_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xffb2);
52+
53+
private boolean weightStabilized = false;
54+
private float stableWeightKg = 0.0f;
55+
56+
private boolean isSupportPH = false;
57+
private boolean isSupportHR = false;
58+
59+
private boolean balanceStabilized = false;
60+
private float stableBalanceL = 0.0f;
61+
62+
private double impedance = 0.0f;
63+
64+
private ScaleMeasurement scaleData;
65+
66+
public BluetoothActiveEraBF06(Context context) {
67+
super(context);
68+
}
69+
70+
private byte[] getConfigurationPacket() {
71+
// current time
72+
long now = Instant.now().toEpochMilli() / 1000;
73+
byte[] time = Converters.toInt32Be(now);
74+
75+
final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser();
76+
int height = (int) Math.ceil(selectedUser.getBodyHeight());
77+
int age = selectedUser.getAge();
78+
int gender = selectedUser.getGender() == Converters.Gender.FEMALE ? 0x02 : 0x01;
79+
80+
int units = 0; // KG
81+
switch(selectedUser.getScaleUnit()) {
82+
case LB:
83+
units = 1;
84+
break;
85+
case ST:
86+
units = 2;
87+
break;
88+
};
89+
90+
int initialWeight = (int) Math.ceil(selectedUser.getInitialWeight() * 100);
91+
byte[] initialWeightBytes = Converters.toInt16Be(initialWeight);
92+
93+
byte[] targetWeightBytes;
94+
float goalWeight = selectedUser.getGoalWeight();
95+
if (goalWeight > -1) {
96+
int targetWeight = (int) Math.ceil(goalWeight * 100);
97+
targetWeightBytes = Converters.toInt16Be(targetWeight);
98+
} else {
99+
targetWeightBytes = initialWeightBytes;
100+
}
101+
102+
byte[] configBytes = new byte[]{
103+
/* 0x00 */ MAGIC_BYTE,
104+
/* 0x01 */ DEVICE_TYPE,
105+
/* 0x02 */ time[0],
106+
/* 0x03 */ time[1],
107+
/* 0x04 */ time[2],
108+
/* 0x05 */ time[3],
109+
/* 0x06 */ 0x04,
110+
/* 0x07 */ (byte)units,
111+
/* 0x08 */ 0x01, // user id ?
112+
/* 0x09 */ (byte)(height & 0xFF),
113+
/* 0x0a */ initialWeightBytes[0],
114+
/* 0x0b */ initialWeightBytes[1],
115+
/* 0x0c */ (byte)(age & 0xFF),
116+
/* 0x0d */ (byte)gender,
117+
/* 0x0e */ targetWeightBytes[0],
118+
/* 0x0f */ targetWeightBytes[1],
119+
/* 0x10 */ 0x03,
120+
/* 0x11 */ 0x00,
121+
/* 0x12 */ (byte)0xd0,
122+
/* 0x13 */ (byte)0x00 // checksum
123+
};
124+
125+
return withCorrectCS(configBytes);
126+
}
127+
128+
private void sendConfigurationPacket() {
129+
byte[] packet = getConfigurationPacket();
130+
131+
Timber.d("sending configuration packet: %s", byteInHex(packet));
132+
writeBytes(MEASUREMENT_SERVICE, WRITE_CHARACTERISTIC, packet);
133+
}
134+
135+
@Override
136+
public void onBluetoothNotify(UUID characteristic, byte[] value) {
137+
decodePacket(value);
138+
}
139+
140+
@Override
141+
public String driverName() {
142+
return "Active Era BF-06";
143+
}
144+
145+
@Override
146+
protected boolean onNextStep(int stepNr) {
147+
switch (stepNr) {
148+
case 0:
149+
//Tell device to send us measurements
150+
setNotificationOn(MEASUREMENT_SERVICE, NOTIFICATION_CHARACTERISTIC);
151+
152+
// reset old values
153+
stableWeightKg = 0.0f;
154+
stableBalanceL = 0.0f;
155+
impedance = 0;
156+
weightStabilized = false;
157+
balanceStabilized = false;
158+
scaleData = new ScaleMeasurement();
159+
160+
break;
161+
162+
case 1:
163+
sendConfigurationPacket();
164+
break;
165+
166+
case 2: // weighting ...
167+
sendMessage(R.string.info_step_on_scale, 0);
168+
stopMachineState();
169+
break;
170+
171+
case 3: // weighted ! measuring balance ...
172+
stopMachineState();
173+
break;
174+
175+
case 4: // balanced ! reporting ADC and measuring HR ...
176+
stopMachineState();
177+
break;
178+
179+
case 5: // HR measured! Maybe some historical will follow
180+
Timber.i("Measuring all done!");
181+
182+
scaleData.setDateTime(Calendar.getInstance().getTime());
183+
addScaleMeasurement(scaleData);
184+
default:
185+
return false;
186+
}
187+
188+
return true;
189+
}
190+
191+
192+
private void decodePacket(byte[] pkt) {
193+
if (pkt == null) {
194+
return;
195+
} else if (pkt[0] != MAGIC_BYTE) {
196+
Timber.w("Wrong packet MAGIC");
197+
return;
198+
} else if (pkt.length != 20) {
199+
Timber.w("Wrong packet length %s expected 20", pkt.length);
200+
return;
201+
}
202+
203+
int packetType = pkt[0x12] & 0xFF;
204+
switch (packetType) {
205+
case 0xD5: // weight measurement
206+
byte flags = pkt[0x02];
207+
boolean stabilized = isBitSet(flags, 8);
208+
isSupportHR = isBitSet(flags, 2);
209+
isSupportPH = isBitSet(flags, 3);
210+
211+
float weightKg = (Converters.fromUnsignedInt24Be(pkt, 3) & 0x3FFFF) / 1000.0f;
212+
// TODO: test if it's always in grams ?
213+
if (stabilized && !weightStabilized) {
214+
weightStabilized = true;
215+
stableWeightKg = weightKg;
216+
Timber.i("Measured weight (stable): %.3f", stableWeightKg);
217+
scaleData.setWeight(weightKg);
218+
resumeMachineState();
219+
}
220+
221+
break;
222+
223+
case 0xD0: // balance measuring
224+
byte state = pkt[0x02];
225+
boolean isFinal = state == 0x01;
226+
227+
int weightLRaw = Converters.fromUnsignedInt16Be(pkt, 3);
228+
int percentLRaw = Converters.fromUnsignedInt16Be(pkt, 5);
229+
float weightL = (float)weightLRaw / 100.0f;
230+
float percentL = (float)percentLRaw / 10.0f;
231+
232+
if (isFinal && !balanceStabilized) {
233+
balanceStabilized = true;
234+
stableBalanceL = percentL;
235+
Timber.i("Measured balance (stable): L %.1f R: %.1f [%.2f]", percentL, 100.0f - percentL, weightL);
236+
resumeMachineState();
237+
}
238+
break;
239+
240+
case 0xD6: // reporting ADCs
241+
byte number = pkt[0x02];
242+
if (number == 1) {
243+
double imp = Converters.fromUnsignedInt16Be(pkt, 4);
244+
if (imp >= 1500.0d) {
245+
imp = (((imp - 1000.0d) + ((stableWeightKg * 10.0d) * (-0.4d))) / 0.6d) / 10.0d;
246+
}
247+
impedance = imp;
248+
Timber.i("Measured impedance: %.1f", impedance);
249+
250+
// calculate BIA using measure weight and impedance
251+
if (impedance > 0.0) {
252+
final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser();
253+
int height = (int) Math.ceil(selectedUser.getBodyHeight());
254+
int age = selectedUser.getAge();
255+
int gender = selectedUser.getGender() == Converters.Gender.FEMALE ? 0 : 1;
256+
257+
calculateBIA(height, impedance, stableWeightKg, age, gender);
258+
// TODO: report results
259+
}
260+
261+
} else {
262+
Timber.w("Unsupported number of ADCs: %s", number);
263+
}
264+
265+
stopMachineState();
266+
break;
267+
268+
case 0xD7: // HR measured
269+
int hr = pkt[0x03] & 0xff;
270+
Timber.i("Measured heart rate: %d", hr);
271+
resumeMachineState();
272+
273+
break;
274+
275+
case 0xD8: // historical measurement
276+
parseHistoricalPacket(pkt);
277+
278+
default:
279+
Timber.w("Unsupported packet [%d]: %s", packetType, byteInHex(pkt));
280+
}
281+
282+
}
283+
284+
private byte[] withCorrectCS(byte[] pkt) {
285+
byte[] fixed = Arrays.copyOf(pkt, pkt.length);
286+
fixed[fixed.length - 1] = sumChecksum(fixed, 2, fixed.length - 3);
287+
return fixed;
288+
}
289+
290+
/**
291+
* Calculate BIA parameters
292+
* for now, using forumlas from
293+
* <a href="https://isn.ucsd.edu/courses/beng186b/project/2021/Raj_Sunku_Tsujimoto_Measuring_body_composition_via_body_impedance.pdf">paper</a>
294+
*
295+
* TODO: replace with reverse-engineered library version
296+
*
297+
* @param heightCm
298+
* @param impedanceOhm
299+
* @param weightKg
300+
* @param age - in years
301+
* @param gender - 0 - female, 1 - male
302+
*/
303+
private void calculateBIA(int heightCm, double impedanceOhm, float weightKg, int age, int gender) {
304+
// FFM = 0.36(H2/Z) + 0.162H + 0.289W − 0.134A + 4.83G − 6.83
305+
double fatFreeMass = (0.36d * (Math.pow(heightCm, 2) / impedanceOhm))
306+
+ (0.162d * heightCm)
307+
+ (0.289d * weightKg)
308+
- (0.134 * age)
309+
+ (4.83 * gender)
310+
- 6.83;
311+
312+
double fatMass = weightKg - fatFreeMass;
313+
double bodyFat = fatMass / weightKg * 100.0;
314+
Timber.i("FFM: %.2f, FM: %.2f, BF: %.1f%%", fatFreeMass, fatMass, bodyFat);
315+
}
316+
317+
private void parseHistoricalPacket(byte[] pkt) {
318+
Instant time = Instant.ofEpochSecond(Converters.fromUnsignedInt24Be(pkt, 3));
319+
float weight = (Converters.fromUnsignedInt24Be(pkt, 0x08) & 0x03FFFF) / 1000.0f;
320+
float weightLeft = Converters.fromUnsignedInt16Be(pkt, 0x0b) / 100.0f;
321+
int hr = pkt[0x0d] & 0xff;
322+
int adc = Converters.fromUnsignedInt16Be(pkt, 0x0f);
323+
Timber.i("Historical measurement: %.3f kg, Weight Left: %.2f kg, HR: %d, ADC: %d", weight, weightLeft, hr, adc);
324+
// TODO: store historical results
325+
}
326+
}

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

+3
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ public static BluetoothCommunication createDeviceDriver(Context context, String
145145
if (deviceName.equals("AAA002") || deviceName.equals("AAA007")){
146146
return new BluetoothBroadcastScale(context);
147147
}
148+
if (deviceName.equals("AE BS-06")) {
149+
return new BluetoothActiveEraBF06(context);
150+
}
148151
return null;
149152
}
150153
}

0 commit comments

Comments
 (0)