From 5f9d8f95dc429d7603071c42958e26d62156b199 Mon Sep 17 00:00:00 2001 From: oliexdev Date: Tue, 19 Aug 2025 18:43:13 +0200 Subject: [PATCH] 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. --- .../core/bluetooth/libs/MiScaleLib.java | 25 +- .../scalesJava/BluetoothMiScale2.java | 448 +++++++++++------- 2 files changed, 308 insertions(+), 165 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/MiScaleLib.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/MiScaleLib.java index f536347c..c47c97f2 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/MiScaleLib.java +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/libs/MiScaleLib.java @@ -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)); + } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothMiScale2.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothMiScale2.java index 3aef3984..73a9b6dd 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothMiScale2.java +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothMiScale2.java @@ -1,18 +1,7 @@ -/* Copyright (C) 2014 olie.xdev -* -* 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 -*/ +/* Copyright (C) 2025 olie.xdev + * + * 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 : 0x01 FF FF (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; } }