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 a73440d24..e549e9345 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 @@ -25,13 +25,13 @@ import com.health.openscale.R; import com.health.openscale.core.datatypes.ScaleMeasurement; -import java.util.Date; import java.util.UUID; import timber.log.Timber; -import static java.util.concurrent.TimeUnit.MILLISECONDS; -import static java.util.concurrent.TimeUnit.SECONDS; +import static com.health.openscale.core.bluetooth.lib.TrisaBodyAnalyzeLib.convertJavaTimestampToDevice; +import static com.health.openscale.core.bluetooth.lib.TrisaBodyAnalyzeLib.getInt32; +import static com.health.openscale.core.bluetooth.lib.TrisaBodyAnalyzeLib.parseScaleMeasurementData; /** * Driver for Trisa Body Analyze 4.0. @@ -66,9 +66,6 @@ public class BluetoothTrisaBodyAnalyze extends BluetoothCommunication { private static final byte DOWNLOAD_INFORMATION_BROADCAST_ID_COMMAND = 0x21; private static final byte DOWNLOAD_INFORMATION_ENABLE_DISCONNECT_COMMAND = 0x22; - // Timestamp of 2010-01-01 00:00:00 UTC (or local time?) - private static final long TIMESTAMP_OFFSET_SECONDS = 1262304000L; - /** * Broadcast id, which the scale will include in its Bluetooth alias. This must be set to some * value to complete the pairing process (though the actual value doesn't seem to matter). @@ -250,8 +247,8 @@ private void onChallengeReceived(byte[] data) { int challenge = getInt32(data, 1); int response = challenge ^ password; writeCommand(DOWNLOAD_INFORMATION_RESULT_COMMAND, response); - int timestamp = (int)(System.currentTimeMillis()/1000 - TIMESTAMP_OFFSET_SECONDS); - writeCommand(DOWNLOAD_INFORMATION_UTC_COMMAND, timestamp); + int deviceTimestamp = convertJavaTimestampToDevice(System.currentTimeMillis()); + writeCommand(DOWNLOAD_INFORMATION_UTC_COMMAND, deviceTimestamp); } private void onScaleMeasurumentReceived(byte[] data) { @@ -289,45 +286,6 @@ private void writeCommandBytes(byte[] bytes) { writeBytes(WEIGHT_SCALE_SERVICE_UUID, DOWNLOAD_COMMAND_CHARACTERISTIC_UUID, bytes); } - @Nullable - private 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. - // Check that we have at least weight & timestamp, which is the minimum information that - // ScaleMeasurement needs. - if (data.length < 9 || (data[0] & 1) == 0) { - return null; - } - - double weight = getBase10Float(data, 1); - long timestamp_seconds = TIMESTAMP_OFFSET_SECONDS + (long)getInt32(data, 5); - - ScaleMeasurement measurement = new ScaleMeasurement(); - measurement.setDateTime(new Date(MILLISECONDS.convert(timestamp_seconds, SECONDS))); - measurement.setWeight((float)weight); - // TODO: calculate body composition (if possible) and set those fields too - return measurement; - } - - /** Converts 4 little-endian bytes to a 32-bit integer. */ - private static int getInt32(byte[] data, int offset) { - return (data[offset] & 0xff) | ((data[offset + 1] & 0xff) << 8) | - ((data[offset + 2] & 0xff) << 16) | ((data[offset + 3] & 0xff) << 24); - } - - /** Converts 4 bytes to a floating point number. - * - *
The first three little-endian bytes form the 24-bit mantissa. The last byte contains the
- * signed exponent, applied in base 10.
- */
- private static double getBase10Float(byte[] data, int offset) {
- int mantissa = (data[offset] & 0xff) | ((data[offset + 1] & 0xff) << 8) |
- ((data[offset + 2] & 0xff) << 16);
- int exponent = data[offset + 3]; // note: byte is signed.
- return mantissa * Math.pow(10, exponent);
- }
-
private static String getDevicePasswordKey(String deviceId) {
return SHARED_PREFERENCES_PASSWORD_KEY_PREFIX + deviceId;
}
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
new file mode 100644
index 000000000..a08aa0826
--- /dev/null
+++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/TrisaBodyAnalyzeLib.java
@@ -0,0 +1,92 @@
+/* Copyright (C) 2018 Maks Verver The first three little-endian bytes form the 24-bit mantissa. The last byte contains the
+ * signed exponent, applied in base 10.
+ *
+ * @throws IndexOutOfBoundsException if {@code offset < 0} or {@code offset + 4> data.length}
+ */
+ public static double getBase10Float(byte[] data, int offset) {
+ int mantissa = (data[offset] & 0xff) | ((data[offset + 1] & 0xff) << 8) |
+ ((data[offset + 2] & 0xff) << 16);
+ int exponent = data[offset + 3]; // note: byte is signed.
+ return mantissa * Math.pow(10, exponent);
+ }
+
+ public static int convertJavaTimestampToDevice(long javaTimestampMillis) {
+ return (int)((javaTimestampMillis + 500)/1000 - TIMESTAMP_OFFSET_SECONDS);
+ }
+
+ public static long convertDeviceTimestampToJava(int deviceTimestampSeconds) {
+ return 1000 * (TIMESTAMP_OFFSET_SECONDS + (long)deviceTimestampSeconds);
+ }
+
+ @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.
+ // Check that we have at least weight & timestamp, which is the minimum information that
+ // ScaleMeasurement needs.
+ if (data.length < 9 || (data[0] & 1) == 0) {
+ return null;
+ }
+
+ double weight = 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
+ return measurement;
+ }
+
+ 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
new file mode 100644
index 000000000..4c98e64f7
--- /dev/null
+++ b/android_app/app/src/test/java/com/health/openscale/TrisaBodyAnalyzeLibTest.java
@@ -0,0 +1,138 @@
+package com.health.openscale;
+
+import com.health.openscale.core.datatypes.ScaleMeasurement;
+
+import junit.framework.Assert;
+
+import org.junit.Test;
+
+import java.util.Date;
+
+import static com.health.openscale.core.bluetooth.lib.TrisaBodyAnalyzeLib.convertDeviceTimestampToJava;
+import static com.health.openscale.core.bluetooth.lib.TrisaBodyAnalyzeLib.convertJavaTimestampToDevice;
+import static com.health.openscale.core.bluetooth.lib.TrisaBodyAnalyzeLib.getBase10Float;
+import static com.health.openscale.core.bluetooth.lib.TrisaBodyAnalyzeLib.getInt32;
+import static com.health.openscale.core.bluetooth.lib.TrisaBodyAnalyzeLib.parseScaleMeasurementData;
+import static junit.framework.Assert.assertEquals;
+
+/** Unit tests for {@link com.health.openscale.core.bluetooth.lib.TrisaBodyAnalyzeLib}.*/
+public class TrisaBodyAnalyzeLibTest {
+
+ @Test
+ public void getInt32Tests() {
+ byte[] data = new byte[]{1, 2, 3, 4, 5, 6};
+ assertEquals(0x04030201, getInt32(data, 0));
+ assertEquals(0x05040302, getInt32(data, 1));
+ assertEquals(0x06050403, getInt32(data, 2));
+
+ assertEquals(0xa7bdd385, getInt32(new byte[]{-123, -45, -67, -89}, 0));
+
+ assertThrows(IndexOutOfBoundsException.class, getInt32Runnable(data, -1));
+ assertThrows(IndexOutOfBoundsException.class, getInt32Runnable(data, 5));
+ assertThrows(IndexOutOfBoundsException.class, getInt32Runnable(new byte[]{1,2,3}, 0));
+ }
+
+ @Test
+ public void getBase10FloatTests() {
+ double eps = 1e-9; // margin of error for inexact floating point comparisons
+ assertEquals(0.0, getBase10Float(new byte[]{0, 0, 0, 0}, 0));
+ assertEquals(0.0, getBase10Float(new byte[]{0, 0, 0, -1}, 0));
+ assertEquals(76.1, getBase10Float(new byte[]{-70, 29, 0, -2}, 0), eps);
+ assertEquals(1234.5678, getBase10Float(new byte[]{78, 97, -68, -4}, 0), eps);
+ assertEquals(12345678e127, getBase10Float(new byte[]{78, 97, -68, 127}, 0));
+ assertEquals(12345678e-128, getBase10Float(new byte[]{78, 97, -68, -128}, 0), eps);
+
+ byte[] data = new byte[]{1,2,3,4,5};
+ assertEquals(0x030201*1e4, getBase10Float(data, 0));
+ assertEquals(0x040302*1e5, getBase10Float(data, 1));
+
+ assertThrows(IndexOutOfBoundsException.class, getBase10FloatRunnable(data, -1));
+ assertThrows(IndexOutOfBoundsException.class, getBase10FloatRunnable(data, 5));
+ assertThrows(IndexOutOfBoundsException.class, getBase10FloatRunnable(new byte[]{1,2,3}, 0));
+ }
+
+ @Test
+ public void convertJavaTimestampToDeviceTests() {
+ assertEquals(275852082, convertJavaTimestampToDevice(1538156082000L));
+
+ // Rounds down.
+ assertEquals(275852082, convertJavaTimestampToDevice(1538156082499L));
+
+ // Rounds up.
+ assertEquals(275852083, convertJavaTimestampToDevice(1538156082500L));
+ }
+
+ @Test
+ public void convertDeviceTimestampToJavaTests() {
+ assertEquals(1538156082000L, convertDeviceTimestampToJava(275852082));
+ }
+
+ @Test
+ public void parseScaleMeasurementDataTests() {
+ 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);
+
+ assertEquals(measurement.getWeight(), 76.1f, 1e-6f);
+ assertEquals(new Date(expected_timestamp_seconds * 1000), measurement.getDateTime());
+ }
+
+ /**
+ * Creates a {@link Runnable} that will call getInt32(). In Java 8, this can be done more
+ * easily with a lambda expression at the call site, but we are using Java 7.
+ */
+ private static Runnable getInt32Runnable(final byte[] data, final int offset) {
+ return new Runnable() {
+ @Override
+ public void run() {
+ getInt32(data, offset);
+ }
+ };
+ }
+
+ /**
+ * Creates a {@link Runnable} that will call getBase10Float(). In Java 8, this can be done more
+ * easily with a lambda expression at the call site, but we are using Java 7.
+ */
+ private static Runnable getBase10FloatRunnable(final byte[] data, final int offset) {
+ return new Runnable() {
+ @Override
+ public void run() {
+ getBase10Float(data, offset);
+ }
+ };
+ }
+
+ /**
+ * Runs the given {@link Runnable} and verifies that it throws an exception of class {@code
+ * exceptionClass}. If it does, the exception will be caught and returned. If it does not (i.e.
+ * the runnable throws no exception, or throws an exception of a different class), then {@link
+ * Assert#fail} is called to abort the test.
+ */
+ private static