From 16011b7e674e1e50a8485b2f89f9eff575aa8979 Mon Sep 17 00:00:00 2001 From: Maks Verver Date: Sun, 7 Oct 2018 14:58:41 +0200 Subject: [PATCH] Move static helper methods to TrisaBodyAnalyzeLib and add unit tests for them. --- .../bluetooth/BluetoothTrisaBodyAnalyze.java | 52 +------ .../bluetooth/lib/TrisaBodyAnalyzeLib.java | 92 ++++++++++++ .../openscale/TrisaBodyAnalyzeLibTest.java | 138 ++++++++++++++++++ 3 files changed, 235 insertions(+), 47 deletions(-) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/TrisaBodyAnalyzeLib.java create mode 100644 android_app/app/src/test/java/com/health/openscale/TrisaBodyAnalyzeLibTest.java 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 + * + * 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 + */ +package com.health.openscale.core.bluetooth.lib; + +import android.support.annotation.Nullable; + +import com.health.openscale.core.datatypes.ScaleMeasurement; + +import java.time.Clock; +import java.util.Date; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; + +/** + * Class with static helper methods. This is a separate class for testing purposes. + * + * @see com.health.openscale.core.bluetooth.BluetoothTrisaBodyAnalyze + */ +public class TrisaBodyAnalyzeLib { + + // Timestamp of 2010-01-01 00:00:00 UTC (or local time?) + private static final long TIMESTAMP_OFFSET_SECONDS = 1262304000L; + + /** + * Converts 4 little-endian bytes to a 32-bit integer, starting from {@code offset}. + * + * @throws IndexOutOfBoundsException if {@code offset < 0} or {@code offset + 4> data.length} + */ + public 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, starting from {@code offset}. + * + *

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 T assertThrows(Class exceptionClass, Runnable run) { + try { + run.run(); + Assert.fail("Expected an exception to be thrown."); + } catch (Throwable t) { + if (exceptionClass.isInstance(t)) { + return exceptionClass.cast(t); + } + Assert.fail("Wrong kind of exception was thrown; expected " + exceptionClass + ", received " + t.getClass()); + } + return null; // unreachable, because Assert.fail() throws an exception + } + + /** Parses a colon-separated hex-encoded string like "aa:bb:cc:dd" into an array of bytes. */ + private byte[] hexToBytes(String s) { + String[] parts = s.split(":"); + byte[] bytes = new byte[parts.length]; + for (int i = 0; i < bytes.length; ++i) { + if (parts[i].length() != 2) { + throw new IllegalArgumentException(); + } + bytes[i] = (byte)Integer.parseInt(parts[i], 16); + } + return bytes; + } +}