Skip to content

Commit

Permalink
Move static helper methods to TrisaBodyAnalyzeLib and add unit tests …
Browse files Browse the repository at this point in the history
…for them.
  • Loading branch information
maksverver committed Oct 7, 2018
1 parent ce615c5 commit 16011b7
Show file tree
Hide file tree
Showing 3 changed files with 235 additions and 47 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
*
* <p>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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/* Copyright (C) 2018 Maks Verver <maks@verver.ch>
*
* 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/>
*/
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}.
*
* <p>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() {}
}
Original file line number Diff line number Diff line change
@@ -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 extends Throwable> T assertThrows(Class<T> 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;
}
}

0 comments on commit 16011b7

Please sign in to comment.