From b17c1971fb1d6c38086f262971325c1a13ffa28d Mon Sep 17 00:00:00 2001 From: Maks Verver Date: Fri, 12 Oct 2018 23:09:22 +0200 Subject: [PATCH] Use resistance measurement to calculate body composition data. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coƫfficients were obtained by reverse-engineering the discontinued Trisa Vital Bluetooth app. The resulting figures do not seem to be very accurate. --- .../bluetooth/BluetoothTrisaBodyAnalyze.java | 5 +- .../bluetooth/lib/TrisaBodyAnalyzeLib.java | 70 ++++++++++++++++--- .../openscale/TrisaBodyAnalyzeLibTest.java | 52 ++++++++++++-- 3 files changed, 113 insertions(+), 14 deletions(-) diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothTrisaBodyAnalyze.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothTrisaBodyAnalyze.java index e549e934..6e846e5c 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothTrisaBodyAnalyze.java +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothTrisaBodyAnalyze.java @@ -23,7 +23,9 @@ import android.preference.PreferenceManager; import android.support.annotation.Nullable; import com.health.openscale.R; +import com.health.openscale.core.OpenScale; import com.health.openscale.core.datatypes.ScaleMeasurement; +import com.health.openscale.core.datatypes.ScaleUser; import java.util.UUID; @@ -252,7 +254,8 @@ public class BluetoothTrisaBodyAnalyze extends BluetoothCommunication { } private void onScaleMeasurumentReceived(byte[] data) { - ScaleMeasurement scaleMeasurement = parseScaleMeasurementData(data); + ScaleUser user = OpenScale.getInstance().getSelectedScaleUser(); + ScaleMeasurement scaleMeasurement = parseScaleMeasurementData(data, user); if (scaleMeasurement == null) { Timber.e("Failed to parse scale measure measurement data: %s", byteInHex(data)); return; diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/TrisaBodyAnalyzeLib.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/TrisaBodyAnalyzeLib.java index ccff9009..c25d2f99 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/TrisaBodyAnalyzeLib.java +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/TrisaBodyAnalyzeLib.java @@ -18,6 +18,8 @@ package com.health.openscale.core.bluetooth.lib; import android.support.annotation.Nullable; import com.health.openscale.core.datatypes.ScaleMeasurement; +import com.health.openscale.core.datatypes.ScaleUser; +import com.health.openscale.core.utils.Converters; import java.util.Date; @@ -64,25 +66,75 @@ public class TrisaBodyAnalyzeLib { } @Nullable - public static ScaleMeasurement parseScaleMeasurementData(byte[] data) { - // Byte 0 contains info. - // Byte 1-4 contains weight. - // Byte 5-8 contains timestamp, if bit 0 in info byte is set. + public static ScaleMeasurement parseScaleMeasurementData(byte[] data, @Nullable ScaleUser user) { + // data contains: + // + // 1 byte: info about presence of other fields: + // bit 0: timestamp + // bit 1: resistance1 + // bit 2: resistance2 + // (other bits aren't used here) + // 4 bytes: weight + // 4 bytes: timestamp (if info bit 0 is set) + // 4 bytes: resistance1 (if info bit 1 is set) + // 4 bytes: resistance2 (if info bit 2 is set) + // (following fields aren't used here) + // Check that we have at least weight & timestamp, which is the minimum information that // ScaleMeasurement needs. - if (data.length < 9 || (data[0] & 1) == 0) { + if (data.length < 9) { + return null; // data is too short + } + byte infoByte = data[0]; + boolean hasTimestamp = (infoByte & 1) == 1; + boolean hasResistance1 = (infoByte & 2) == 2; + boolean hasResistance2 = (infoByte & 4) == 4; + if (!hasTimestamp) { return null; } - - double weight = getBase10Float(data, 1); + double weightKg = getBase10Float(data, 1); int deviceTimestamp = getInt32(data, 5); ScaleMeasurement measurement = new ScaleMeasurement(); measurement.setDateTime(new Date(convertDeviceTimestampToJava(deviceTimestamp))); - measurement.setWeight((float)weight); - // TODO: calculate body composition (if possible) and set those fields too + measurement.setWeight((float) weightKg); + + // Only resistance 2 is used; resistance 1 is 0, even if it is present. + int resistance2Offset = 9 + (hasResistance1 ? 4 : 0); + if (hasResistance2 && resistance2Offset + 4 <= data.length && isValidUser(user)) { + // Calculate body composition statistics from measured weight & resistance, combined + // with age, height and sex from the user profile. The accuracy of the resulting figures + // is questionable, but it's better than nothing. Even if the absolute numbers aren't + // very meaningful, it might still be useful to track changes over time. + double resistance2 = getBase10Float(data, resistance2Offset); + int ageYears = user.getAge(); + double heightCm = Converters.toCentimeter(user.getBodyHeight(), user.getMeasureUnit()); + boolean isMale = user.getGender().isMale(); + double impedance = resistance2 < 410 ? 3.0 : 0.3 * (resistance2 - 400); + double bmi = weightKg * 1e4 / (heightCm * heightCm); + double fat = isMale + ? bmi * (1.479 + 4.4e-4 * impedance) + 0.1 * ageYears - 21.764 + : bmi * (1.506 + 3.908e-4 * impedance) + 0.1 * ageYears - 12.834; + double water = isMale + ? 87.51 + (-1.162 * bmi - 0.00813 * impedance + 0.07594 * ageYears) + : 77.721 + (-1.148 * bmi - 0.00573 * impedance + 0.06448 * ageYears); + double muscle = isMale + ? 74.627 + (-0.811 * bmi - 0.00565 * impedance - 0.367 * ageYears) + : 57.0 + (-0.694 * bmi - 0.00344 * impedance - 0.255 * ageYears); + double bone = isMale + ? 7.829 + (-0.0855 * bmi - 5.92e-4 * impedance - 0.0389 * ageYears) + : 7.98 + (-0.0973 * bmi - 4.84e-4 * impedance - 0.036 * ageYears); + measurement.setFat((float) fat); + measurement.setWater((float) water); + measurement.setMuscle((float) muscle); + measurement.setBone((float) bone); + } return measurement; } + private static boolean isValidUser(@Nullable ScaleUser user) { + return user != null && user.getAge() > 0 && user.getBodyHeight() > 0; + } + private TrisaBodyAnalyzeLib() {} } diff --git a/android_app/app/src/test/java/com/health/openscale/TrisaBodyAnalyzeLibTest.java b/android_app/app/src/test/java/com/health/openscale/TrisaBodyAnalyzeLibTest.java index 4c98e64f..c54d69e2 100644 --- a/android_app/app/src/test/java/com/health/openscale/TrisaBodyAnalyzeLibTest.java +++ b/android_app/app/src/test/java/com/health/openscale/TrisaBodyAnalyzeLibTest.java @@ -1,12 +1,16 @@ package com.health.openscale; import com.health.openscale.core.datatypes.ScaleMeasurement; +import com.health.openscale.core.datatypes.ScaleUser; +import com.health.openscale.core.utils.Converters; import junit.framework.Assert; import org.junit.Test; +import java.util.Calendar; import java.util.Date; +import java.util.GregorianCalendar; import static com.health.openscale.core.bluetooth.lib.TrisaBodyAnalyzeLib.convertDeviceTimestampToJava; import static com.health.openscale.core.bluetooth.lib.TrisaBodyAnalyzeLib.convertJavaTimestampToDevice; @@ -68,14 +72,49 @@ public class TrisaBodyAnalyzeLibTest { } @Test - public void parseScaleMeasurementDataTests() { + public void parseScaleMeasurementData_validUserData() { + long expected_timestamp_seconds = 1539205852L; // Wed Oct 10 21:10:52 UTC 2018 + byte[] bytes = hexToBytes("9f:b0:1d:00:fe:dc:2f:81:10:00:00:00:ff:0a:15:00:ff:00:09:00"); + + ScaleUser user = new ScaleUser(); + user.setGender(Converters.Gender.MALE); + user.setBirthday(ageToBirthday(36)); + user.setBodyHeight(186); + user.setMeasureUnit(Converters.MeasureUnit.CM); + + ScaleMeasurement measurement = parseScaleMeasurementData(bytes, user); + + float eps = 1e-3f; + assertEquals(76.0f, measurement.getWeight(), eps); + assertEquals(new Date(expected_timestamp_seconds * 1000), measurement.getDateTime()); + assertEquals(14.728368f, measurement.getFat(), eps); + assertEquals(64.37914f, measurement.getWater(), eps); + assertEquals(43.36414f, measurement.getMuscle(), eps); + assertEquals(4.525733f, measurement.getBone()); + } + + @Test + public void parseScaleMeasurementData_missingUserData() { long expected_timestamp_seconds = 1538156082L; // Fri Sep 28 17:34:42 UTC 2018 byte[] bytes = hexToBytes("9f:ba:1d:00:fe:32:2b:71:10:00:00:00:ff:8d:14:00:ff:00:09:00"); - ScaleMeasurement measurement = parseScaleMeasurementData(bytes); + ScaleMeasurement measurement = parseScaleMeasurementData(bytes, null); - assertEquals(measurement.getWeight(), 76.1f, 1e-6f); + assertEquals(76.1f, measurement.getWeight(), 1e-3f); assertEquals(new Date(expected_timestamp_seconds * 1000), measurement.getDateTime()); + assertEquals(0f, measurement.getFat()); + } + + @Test + public void parseScaleMeasurementData_invalidUserData() { + long expected_timestamp_seconds = 1538156082L; // Fri Sep 28 17:34:42 UTC 2018 + byte[] bytes = hexToBytes("9f:ba:1d:00:fe:32:2b:71:10:00:00:00:ff:8d:14:00:ff:00:09:00"); + + ScaleMeasurement measurement = parseScaleMeasurementData(bytes, new ScaleUser()); + + assertEquals(76.1f, measurement.getWeight(), 1e-3f); + assertEquals(new Date(expected_timestamp_seconds * 1000), measurement.getDateTime()); + assertEquals(0f, measurement.getFat()); } /** @@ -124,7 +163,7 @@ public class TrisaBodyAnalyzeLibTest { } /** Parses a colon-separated hex-encoded string like "aa:bb:cc:dd" into an array of bytes. */ - private byte[] hexToBytes(String s) { + private static byte[] hexToBytes(String s) { String[] parts = s.split(":"); byte[] bytes = new byte[parts.length]; for (int i = 0; i < bytes.length; ++i) { @@ -135,4 +174,9 @@ public class TrisaBodyAnalyzeLibTest { } return bytes; } + + private static Date ageToBirthday(int years) { + int currentYear = GregorianCalendar.getInstance().get(Calendar.YEAR); + return new GregorianCalendar(currentYear - years, Calendar.JANUARY, 1).getTime(); + } }