1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-08-21 16:02:04 +02:00

Refactor BluetoothMiScale2 for improved history handling and more reliable skeletal muscle mass calculation in MiScaleLib using the Janssen BIA equation, with a fallback to a lean body mass ratio if impedance is unavailable.

This commit is contained in:
oliexdev
2025-08-19 18:43:13 +02:00
parent 94f23467ca
commit 5f9d8f95dc
2 changed files with 308 additions and 165 deletions

View File

@@ -57,8 +57,27 @@ public class MiScaleLib {
return leanBodyMass;
}
/**
* Skeletal Muscle Mass (kg) using Janssen et al. BIA equation.
* If impedance is non-positive, falls back to LBM * ratio.
*/
public float getMuscle(float weight, float impedance) {
return this.getLBM(weight,impedance); // this is wrong but coherent with MiFit app behaviour
if (weight <= 0f) return 0f;
float smm;
if (impedance > 0f) {
// Janssen et al. BIA equation for Skeletal Muscle Mass (kg)
float h2_over_r = (height * height) / impedance;
smm = 0.401f * h2_over_r + 3.825f * sex - 0.071f * age + 5.102f;
} else {
// Fallback: approximate as fraction of LBM
float lbm = getLBM(weight, impedance);
float ratio = (sex == 1) ? 0.52f : 0.46f;
smm = lbm * ratio;
}
float percent = (smm / weight) * 100f;
return clamp(percent, 10f, 60f); // clamp to plausible %
}
public float getWater(float weight, float impedance) {
@@ -169,5 +188,9 @@ public class MiScaleLib {
return bodyFat;
}
private static float clamp(float v, float lo, float hi) {
return Math.max(lo, Math.min(hi, v));
}
}

View File

@@ -1,18 +1,7 @@
/* Copyright (C) 2014 olie.xdev <olie.xdev@googlemail.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
*/
/* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* This program is free software: GPLv3 or later.
*/
package com.health.openscale.core.bluetooth.scalesJava;
@@ -29,211 +18,342 @@ import com.health.openscale.core.data.GenderType;
import com.health.openscale.core.utils.Converters;
import com.health.openscale.core.utils.LogManager;
import java.io.ByteArrayOutputStream;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Date;
import java.util.Random;
import java.util.UUID;
public class BluetoothMiScale2 extends BluetoothCommunication {
private final String TAG = "BluetoothMiScale2";
private final UUID WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC = UUID.fromString("00002a2f-0000-3512-2118-0009af100700");
private static final String TAG = "BluetoothMiScale2";
private final UUID WEIGHT_CUSTOM_SERVICE = UUID.fromString("00001530-0000-3512-2118-0009af100700");
private final UUID WEIGHT_CUSTOM_CONFIG = UUID.fromString("00001542-0000-3512-2118-0009af100700");
// Mi custom history characteristic
private static final UUID WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC =
UUID.fromString("00002a2f-0000-3512-2118-0009af100700");
public BluetoothMiScale2(Context context) {
super(context);
}
// Mi custom service + config for unit command
private static final UUID WEIGHT_CUSTOM_SERVICE =
UUID.fromString("00001530-0000-3512-2118-0009af100700");
private static final UUID WEIGHT_CUSTOM_CONFIG =
UUID.fromString("00001542-0000-3512-2118-0009af100700");
// Enable history (helps after battery reset)
private static final byte[] ENABLE_HISTORY_MAGIC = new byte[]{0x01, (byte) 0x96, (byte) 0x8a, (byte) 0xbd, 0x62};
// History reassembly buffer (notifications can fragment)
private final ByteArrayOutputStream histBuf = new ByteArrayOutputStream();
private int pendingHistoryCount = -1;
private int importedHistory = 0;
// warn only once per session if we see "unexpected" flags in history status byte
private boolean historyUnexpectedFlagWarned = false;
public BluetoothMiScale2(Context context) { super(context); }
@Override
public String driverName() {
return "Xiaomi Mi Scale v2";
}
public String driverName() { return "Xiaomi Mi Scale v2"; }
@Override
public void onBluetoothNotify(UUID characteristic, byte[] value) {
final byte[] data = value;
if (value == null || value.length == 0) return;
if (data != null && data.length > 0) {
LogManager.d(TAG, String.format("DataChange hex data: %s", byteInHex(data)));
LogManager.d(TAG, "Notify " + characteristic + " len=" + value.length + " data=" + byteInHex(value));
// Stop command from mi scale received
if (data[0] == 0x03) {
LogManager.d(TAG, "Scale stop byte received");
// send stop command to mi scale
writeBytes(BluetoothGattUuid.SERVICE_BODY_COMPOSITION, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, new byte[]{0x03});
// acknowledge that you received the last history data
int uniqueNumber = getUniqueNumber();
byte[] userIdentifier = new byte[]{(byte)0x04, (byte)0xFF, (byte)0xFF, (byte) ((uniqueNumber & 0xFF00) >> 8), (byte) ((uniqueNumber & 0xFF) >> 0)};
writeBytes(BluetoothGattUuid.SERVICE_BODY_COMPOSITION, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, userIdentifier);
resumeMachineState();
// STOP (end of history transfer)
if (value.length == 1 && value[0] == 0x03) {
// Check for truncated leftover BEFORE flush (for debug visibility)
int leftoverLen = histBuf.size();
if (leftoverLen % 10 != 0) {
LogManager.w(TAG, "History STOP with truncated fragment: " + leftoverLen + " byte(s) not multiple of 10 (ignored)", null);
}
if (data.length == 13) {
parseBytes(data);
}
LogManager.d(TAG, "History STOP received; flush + ACK");
flushHistoryBuffer(); // parse any full 10-byte records left
// ACK stop
writeBytes(BluetoothGattUuid.SERVICE_BODY_COMPOSITION, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, new byte[]{0x03});
// 0x04 + uniq acknowledge
int uniq = getUniqueNumber();
byte[] ack = new byte[]{0x04, (byte) 0xFF, (byte) 0xFF, (byte) ((uniq >> 8) & 0xFF), (byte) (uniq & 0xFF)};
writeBytes(BluetoothGattUuid.SERVICE_BODY_COMPOSITION, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, ack);
LogManager.i(TAG, "History import done: " + importedHistory + " record(s)");
if (pendingHistoryCount == 0) {
LogManager.i(TAG, "Scale reported no new history records.");
}
pendingHistoryCount = -1;
importedHistory = 0;
histBufReset();
// keep connection logic to the state machine
resumeMachineState();
return;
}
}
// Live frame (13 bytes)
if (value.length == 13) {
parseLiveFrame(value);
return;
}
// Rare: two live frames combined in one notify (2x13)
if (value.length == 26) {
LogManager.d(TAG, "26-byte payload -> split into 2x13 live frames");
parseLiveFrame(Arrays.copyOfRange(value, 0, 13));
parseLiveFrame(Arrays.copyOfRange(value, 13, 26));
return;
}
// Response to 0x01 FFFF <uniq>: 0x01 <count> FF FF <uniqHi> <uniqLo> (6..7 bytes)
if (value.length >= 6 && value[0] == 0x01 && (value[2] == (byte) 0xFF)) {
pendingHistoryCount = (value[1] & 0xFF);
LogManager.i(TAG, "History count: " + pendingHistoryCount);
return;
}
// Assume history data chunk (may be fragmented/concatenated)
appendAndParseHistory(value);
}
@Override
protected boolean onNextStep(int stepNr) {
switch (stepNr) {
case 0:
// set scale units
final ScaleUser selectedUser = getSelectedScaleUser();
byte[] setUnitCmd = new byte[]{(byte)0x06, (byte)0x04, (byte)0x00, (byte) selectedUser.getScaleUnit().toInt()};
case 0: { // set unit on the scale
final ScaleUser u = getSelectedScaleUser();
byte[] setUnitCmd = new byte[]{0x06, 0x04, 0x00, (byte) u.getScaleUnit().toInt()};
writeBytes(WEIGHT_CUSTOM_SERVICE, WEIGHT_CUSTOM_CONFIG, setUnitCmd);
LogManager.d(TAG, "Unit set cmd: " + byteInHex(setUnitCmd));
break;
case 1:
// set current time
Calendar currentDateTime = Calendar.getInstance();
int year = currentDateTime.get(Calendar.YEAR);
byte month = (byte)(currentDateTime.get(Calendar.MONTH)+1);
byte day = (byte)currentDateTime.get(Calendar.DAY_OF_MONTH);
byte hour = (byte)currentDateTime.get(Calendar.HOUR_OF_DAY);
byte min = (byte)currentDateTime.get(Calendar.MINUTE);
byte sec = (byte)currentDateTime.get(Calendar.SECOND);
byte[] dateTimeByte = {(byte)(year), (byte)(year >> 8), month, day, hour, min, sec, 0x03, 0x00, 0x00};
writeBytes(BluetoothGattUuid.SERVICE_BODY_COMPOSITION, BluetoothGattUuid.CHARACTERISTIC_CURRENT_TIME, dateTimeByte);
}
case 1: { // set current time
Calendar c = Calendar.getInstance();
int year = c.get(Calendar.YEAR);
byte[] dateTime = new byte[]{
(byte) (year), (byte) (year >> 8),
(byte) (c.get(Calendar.MONTH) + 1),
(byte) c.get(Calendar.DAY_OF_MONTH),
(byte) c.get(Calendar.HOUR_OF_DAY),
(byte) c.get(Calendar.MINUTE),
(byte) c.get(Calendar.SECOND),
0x03, 0x00, 0x00
};
writeBytes(BluetoothGattUuid.SERVICE_BODY_COMPOSITION, BluetoothGattUuid.CHARACTERISTIC_CURRENT_TIME, dateTime);
LogManager.d(TAG, "Current time written");
break;
case 2:
// set notification on for weight measurement history
}
case 2: { // enable history notifications + magic
setNotificationOn(BluetoothGattUuid.SERVICE_BODY_COMPOSITION, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC);
writeBytes(BluetoothGattUuid.SERVICE_BODY_COMPOSITION, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, ENABLE_HISTORY_MAGIC);
LogManager.d(TAG, "History notify ON + magic sent");
break;
case 3:
// configure scale to get only last measurements
int uniqueNumber = getUniqueNumber();
byte[] userIdentifier = new byte[]{(byte)0x01, (byte)0xFF, (byte)0xFF, (byte) ((uniqueNumber & 0xFF00) >> 8), (byte) ((uniqueNumber & 0xFF) >> 0)};
writeBytes(BluetoothGattUuid.SERVICE_BODY_COMPOSITION, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, userIdentifier);
}
case 3: { // request "only last" measurements (per-user marker)
int uniq = getUniqueNumber();
byte[] req = new byte[]{0x01, (byte) 0xFF, (byte) 0xFF, (byte) ((uniq >> 8) & 0xFF), (byte) (uniq & 0xFF)};
writeBytes(BluetoothGattUuid.SERVICE_BODY_COMPOSITION, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, req);
LogManager.d(TAG, "History count request: " + byteInHex(req));
break;
case 4:
// invoke receiving history data
}
case 4: { // trigger history transfer
writeBytes(BluetoothGattUuid.SERVICE_BODY_COMPOSITION, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, new byte[]{0x02});
stopMachineState();
LogManager.d(TAG, "History transfer triggered (0x02)");
stopMachineState(); // wait for notifications
break;
}
default:
return false;
}
return true;
}
private void parseBytes(byte[] data) {
// --- Parsing ---
private void parseLiveFrame(byte[] data) {
// ctrl bytes
final byte c0 = data[0];
final byte c1 = data[1];
final boolean isLbs = isBitSet(c0, 0);
final boolean isCatty = isBitSet(c1, 6); // catty/jin
final boolean isStabilized = isBitSet(c1, 5);
final boolean isRemoved = isBitSet(c1, 7);
final boolean isImpedance = isBitSet(c1, 1);
if (!isStabilized || isRemoved) {
LogManager.d(TAG, "Live ignored (unstable/removed)");
return;
}
final int year = ((data[3] & 0xFF) << 8) | (data[4] & 0xFF);
final int month = (data[5] & 0xFF);
final int day = (data[6] & 0xFF);
final int hour = (data[7] & 0xFF);
final int minute = (data[8] & 0xFF);
int weightRaw = ((data[12] & 0xFF) << 8) | (data[11] & 0xFF);
float weight = (isLbs || isCatty) ? (weightRaw / 100.0f) : (weightRaw / 200.0f);
float impedance = 0.0f;
if (isImpedance) {
impedance = ((data[10] & 0xFF) << 8) | (data[9] & 0xFF);
LogManager.d(TAG, "Impedance: " + impedance + " Ω");
}
try {
final byte ctrlByte0 = data[0];
final byte ctrlByte1 = data[1];
Date dt = new SimpleDateFormat("yyyy/MM/dd/HH/mm")
.parse(year + "/" + month + "/" + day + "/" + hour + "/" + minute);
final boolean isWeightRemoved = isBitSet(ctrlByte1, 7);
final boolean isStabilized = isBitSet(ctrlByte1, 5);
final boolean isLBSUnit = isBitSet(ctrlByte0, 0);
final boolean isCattyUnit = isBitSet(ctrlByte1, 6);
final boolean isImpedance = isBitSet(ctrlByte1, 1);
if (isStabilized && !isWeightRemoved) {
final int year = ((data[3] & 0xFF) << 8) | (data[2] & 0xFF);
final int month = (int) data[4];
final int day = (int) data[5];
final int hours = (int) data[6];
final int min = (int) data[7];
final int sec = (int) data[8];
float weight;
float impedance = 0.0f;
if (isLBSUnit || isCattyUnit) {
weight = (float) (((data[12] & 0xFF) << 8) | (data[11] & 0xFF)) / 100.0f;
} else {
weight = (float) (((data[12] & 0xFF) << 8) | (data[11] & 0xFF)) / 200.0f;
}
if (isImpedance) {
impedance = ((data[10] & 0xFF) << 8) | (data[9] & 0xFF);
LogManager.d(TAG, "impedance value is " + impedance);
}
String date_string = year + "/" + month + "/" + day + "/" + hours + "/" + min;
Date date_time = new SimpleDateFormat("yyyy/MM/dd/HH/mm").parse(date_string);
// Is the year plausible? Check if the year is in the range of 20 years...
if (validateDate(date_time, 20)) {
final ScaleUser scaleUser = getSelectedScaleUser();
ScaleMeasurement scaleBtData = new ScaleMeasurement();
scaleBtData.setWeight(Converters.toKilogram(weight, scaleUser.getScaleUnit()));
scaleBtData.setDateTime(date_time);
int sex;
if (scaleUser.getGender() == GenderType.MALE) {
sex = 1;
} else {
sex = 0;
}
if (impedance != 0.0f) {
MiScaleLib miScaleLib = new MiScaleLib(sex, scaleUser.getAge(), scaleUser.getBodyHeight());
scaleBtData.setWater(miScaleLib.getWater(weight, impedance));
scaleBtData.setVisceralFat(miScaleLib.getVisceralFat(weight));
scaleBtData.setFat(miScaleLib.getBodyFat(weight, impedance));
scaleBtData.setMuscle((100.0f / weight) * miScaleLib.getMuscle(weight, impedance)); // convert muscle in kg to percent
scaleBtData.setLbm(miScaleLib.getLBM(weight, impedance));
scaleBtData.setBone(miScaleLib.getBoneMass(weight, impedance));
} else {
LogManager.d(TAG, "Impedance value is zero");
}
addScaleMeasurement(scaleBtData);
} else {
LogManager.e(TAG,String.format("Invalid Mi scale weight year %d", year), null);
}
if (!validateDate(dt, 20)) {
LogManager.w(TAG, "Live date out of range: " + dt, null);
return;
}
final ScaleUser user = getSelectedScaleUser();
ScaleMeasurement m = new ScaleMeasurement();
m.setWeight(Converters.toKilogram(weight, user.getScaleUnit()));
m.setDateTime(dt);
if (impedance > 0.0f) {
int sex = (user.getGender() == GenderType.MALE) ? 1 : 0;
MiScaleLib lib = new MiScaleLib(sex, user.getAge(), user.getBodyHeight());
m.setWater(lib.getWater(weight, impedance));
m.setVisceralFat(lib.getVisceralFat(weight));
m.setFat(lib.getBodyFat(weight, impedance));
m.setMuscle(lib.getMuscle(weight, impedance)); // expects % from MiScaleLib
m.setLbm(lib.getLBM(weight, impedance));
m.setBone(lib.getBoneMass(weight, impedance));
}
addScaleMeasurement(m);
LogManager.i(TAG, "Live saved @ " + dt + " kg=" + m.getWeight());
} catch (ParseException e) {
setBluetoothStatus(UNEXPECTED_ERROR, "Error while decoding bluetooth date string (" + e.getMessage() + ")");
setBluetoothStatus(UNEXPECTED_ERROR, "Live date parse error (" + e.getMessage() + ")");
}
}
private boolean validateDate(Date weightDate, int range) {
// History record (10 bytes): [status][weightLE(2)][yearLE(2)][mon][day][h][m][s]
private boolean parseHistoryRecord10(byte[] data) {
if (data.length != 10) return false;
Calendar currentDatePos = Calendar.getInstance();
currentDatePos.add(Calendar.YEAR, range);
final byte status = data[0];
final boolean isLbs = (status & 0x01) != 0;
final boolean isCatty = (status & 0x10) != 0;
final boolean isStab = (status & 0x20) != 0;
final boolean isRem = (status & 0x80) != 0;
Calendar currentDateNeg = Calendar.getInstance();
currentDateNeg.add(Calendar.YEAR, -range);
if (weightDate.before(currentDatePos.getTime()) && weightDate.after(currentDateNeg.getTime())) {
return true;
// detect "unexpected" bits in history status (there is no impedance in 10B format)
// bit1 and bit2 are unspecified in most reverse-engineering notes; warn once if we see them.
if (!historyUnexpectedFlagWarned) {
boolean bit1 = (status & 0x02) != 0; // unknown
boolean bit2 = (status & 0x04) != 0; // "partial data" in some docs
if (bit1 || bit2) {
LogManager.w(TAG, "History status has unexpected flag(s): "
+ (bit1 ? "bit1 " : "") + (bit2 ? "bit2 " : "")
+ "— ignoring (history carries no impedance)", null);
historyUnexpectedFlagWarned = true;
}
}
return false;
if (!isStab || isRem) return false;
int weightRaw = ((data[2] & 0xFF) << 8) | (data[1] & 0xFF);
float weight = (isLbs || isCatty) ? (weightRaw / 100.0f) : (weightRaw / 200.0f);
int year = ((data[4] & 0xFF) << 8) | (data[3] & 0xFF);
int month = data[5] & 0xFF;
int day = data[6] & 0xFF;
int hour = data[7] & 0xFF;
int minute = data[8] & 0xFF;
// int second = data[9] & 0xFF;
try {
Date dt = new SimpleDateFormat("yyyy/MM/dd/HH/mm")
.parse(year + "/" + month + "/" + day + "/" + hour + "/" + minute);
if (!validateDate(dt, 20)) {
LogManager.w(TAG, "History date out of range: " + dt, null);
return false;
}
final ScaleUser user = getSelectedScaleUser();
ScaleMeasurement m = new ScaleMeasurement();
m.setWeight(Converters.toKilogram(weight, user.getScaleUnit()));
m.setDateTime(dt);
// No impedance expected in history; BIA fields left empty.
addScaleMeasurement(m);
return true;
} catch (ParseException e) {
setBluetoothStatus(UNEXPECTED_ERROR, "History date parse error (" + e.getMessage() + ")");
return false;
}
}
private void appendAndParseHistory(byte[] chunk) {
try {
histBuf.write(chunk, 0, chunk.length);
byte[] buf = histBuf.toByteArray();
int full = (buf.length / 10) * 10;
if (full >= 10) {
int ok = 0;
for (int off = 0; off < full; off += 10) {
if (parseHistoryRecord10(Arrays.copyOfRange(buf, off, off + 10))) ok++;
}
if (ok > 0) {
importedHistory += ok;
LogManager.d(TAG, "History parsed: " + ok + " record(s) from " + full + " bytes");
}
// keep remainder
histBufReset();
if (buf.length > full) {
histBuf.write(buf, full, buf.length - full);
}
}
} catch (Exception e) {
LogManager.e(TAG, "History buffer append failed: " + e.getMessage(), e);
histBufReset();
}
}
private void flushHistoryBuffer() {
byte[] leftover = histBuf.toByteArray();
if (leftover.length >= 10 && leftover.length % 10 == 0) {
int ok = 0;
for (int off = 0; off < leftover.length; off += 10) {
if (parseHistoryRecord10(Arrays.copyOfRange(leftover, off, off + 10))) ok++;
}
if (ok > 0) {
importedHistory += ok;
LogManager.d(TAG, "History flush parsed: " + ok + " record(s)");
}
}
histBufReset();
}
private void histBufReset() {
try { histBuf.reset(); } catch (Exception ignored) {}
}
// --- Utils ---
private boolean validateDate(Date weightDate, int rangeYears) {
Calendar max = Calendar.getInstance(); max.add(Calendar.YEAR, rangeYears);
Calendar min = Calendar.getInstance(); min.add(Calendar.YEAR, -rangeYears);
return weightDate.before(max.getTime()) && weightDate.after(min.getTime());
}
private int getUniqueNumber() {
int uniqueNumber;
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
uniqueNumber = prefs.getInt("uniqueNumber", 0x00);
if (uniqueNumber == 0x00) {
Random r = new Random();
uniqueNumber = r.nextInt(65535 - 100 + 1) + 100;
prefs.edit().putInt("uniqueNumber", uniqueNumber).apply();
int n = prefs.getInt("uniqueNumber", 0x00);
if (n == 0x00) {
n = new Random().nextInt(65535 - 100 + 1) + 100;
prefs.edit().putInt("uniqueNumber", n).apply();
}
int userId = getSelectedScaleUser().getId();
return uniqueNumber + userId;
return n + userId;
}
}