diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothBeurerSanitas.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothBeurerSanitas.java index 78373835..a492cfc8 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothBeurerSanitas.java +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothBeurerSanitas.java @@ -428,6 +428,10 @@ public class BluetoothBeurerSanitas extends BluetoothCommunication { batteryLevel, weightThreshold, bodyFatThreshold, currentUnit, userExists, userReferWeightExists, userMeasurementExist, scaleVersion); + if (batteryLevel <= 10) { + sendMessage(R.string.info_scale_low_battery, batteryLevel); + } + byte requestedUnit = (byte) currentUnit; ScaleUser user = OpenScale.getInstance().getSelectedScaleUser(); switch (user.getScaleUnit()) { diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothCommunication.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothCommunication.java index 3395ac7a..c177189e 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothCommunication.java +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothCommunication.java @@ -27,6 +27,7 @@ import android.os.Looper; import androidx.core.content.ContextCompat; +import com.health.openscale.R; import com.health.openscale.core.datatypes.ScaleMeasurement; import com.welie.blessed.BluetoothCentral; import com.welie.blessed.BluetoothCentralCallback; @@ -360,6 +361,10 @@ public abstract class BluetoothCommunication { public void onConnectionFailed(BluetoothPeripheral peripheral, final int status) { Timber.e(String.format("connection '%s' failed with status %d", peripheral.getName(), status )); setBluetoothStatus(BT_STATUS.CONNECTION_LOST); + + if (status == 8) { + sendMessage(R.string.info_bluetooth_connection_error_scale_offline, 0); + } } @Override diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothFactory.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothFactory.java index 672f77fd..3918f497 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothFactory.java +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothFactory.java @@ -113,6 +113,9 @@ public class BluetoothFactory { if (deviceName.startsWith("QN-Scale")) { return new BluetoothQNScale(context); } + if (deviceName.startsWith("Shape200") || deviceName.startsWith("Shape100") || deviceName.startsWith("Shape50") || deviceName.startsWith("Style100")) { + return new BluetoothSoehnle(context); + } return null; } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothGattUuid.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothGattUuid.java index b00a2a17..cb82e842 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothGattUuid.java +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothGattUuid.java @@ -92,6 +92,9 @@ public class BluetoothGattUuid { public static final UUID CHARACTERISTIC_BATTERY_LEVEL = fromShortCode(0x2A19); public static final UUID CHARACTERISTIC_CHANGE_INCREMENT = fromShortCode(0x2a99); public static final UUID CHARACTERISTIC_USER_CONTROL_POINT = fromShortCode(0x2A9F); + public static final UUID CHARACTERISTIC_USER_AGE = fromShortCode(0x2A80); + public static final UUID CHARACTERISTIC_USER_GENDER = fromShortCode(0x2A8C); + public static final UUID CHARACTERISTIC_USER_HEIGHT = fromShortCode(0x2A8E); // https://www.bluetooth.com/specifications/gatt/descriptors public static final UUID DESCRIPTOR_CLIENT_CHARACTERISTIC_CONFIGURATION = fromShortCode(0x2902); diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothSoehnle.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothSoehnle.java new file mode 100644 index 00000000..481eb8f4 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/BluetoothSoehnle.java @@ -0,0 +1,275 @@ +/* Copyright (C) 2019 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 +*/ + +package com.health.openscale.core.bluetooth; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; + +import com.health.openscale.R; +import com.health.openscale.core.OpenScale; +import com.health.openscale.core.bluetooth.lib.SoehnleLib; +import com.health.openscale.core.datatypes.ScaleMeasurement; +import com.health.openscale.core.datatypes.ScaleUser; +import com.health.openscale.core.utils.Converters; +import com.welie.blessed.BluetoothBytesParser; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.List; +import java.util.UUID; + +import timber.log.Timber; + +public class BluetoothSoehnle extends BluetoothCommunication { + private final UUID WEIGHT_CUSTOM_SERVICE = UUID.fromString("352e3000-28e9-40b8-a361-6db4cca4147c"); + private final UUID WEIGHT_CUSTOM_A_CHARACTERISTIC = UUID.fromString("352e3001-28e9-40b8-a361-6db4cca4147c"); // notify, read + private final UUID WEIGHT_CUSTOM_B_CHARACTERISTIC = UUID.fromString("352e3004-28e9-40b8-a361-6db4cca4147c"); // notify, read + private final UUID WEIGHT_CUSTOM_CMD_CHARACTERISTIC = UUID.fromString("352e3002-28e9-40b8-a361-6db4cca4147c"); // write + + SharedPreferences prefs; + + public BluetoothSoehnle(Context context) { + super(context); + prefs = PreferenceManager.getDefaultSharedPreferences(context); + } + + @Override + public String driverName() { + return "Soehnle Scale"; + } + + @Override + protected boolean onNextStep(int stepNr) { + switch (stepNr) { + case 0: + List openScaleUserList = OpenScale.getInstance().getScaleUserList(); + + int index = -1; + + // check if an openScale user is stored as a Soehnle user otherwise do a factory reset + for (ScaleUser openScaleUser : openScaleUserList) { + index = getSoehnleUserIndex(openScaleUser.getId()); + if (index != -1) { + break; + } + } + + if (index == -1) { + invokeScaleFactoryReset(); + } + break; + case 1: + setNotificationOn(BluetoothGattUuid.SERVICE_BATTERY_LEVEL, BluetoothGattUuid.CHARACTERISTIC_BATTERY_LEVEL); + readBytes(BluetoothGattUuid.SERVICE_BATTERY_LEVEL, BluetoothGattUuid.CHARACTERISTIC_BATTERY_LEVEL); + break; + case 2: + // Write the current time + BluetoothBytesParser parser = new BluetoothBytesParser(); + parser.setCurrentTime(Calendar.getInstance()); + writeBytes(BluetoothGattUuid.SERVICE_CURRENT_TIME, BluetoothGattUuid.CHARACTERISTIC_CURRENT_TIME, parser.getValue()); + break; + case 3: + // Turn on notification for User Data Service + setNotificationOn(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_CONTROL_POINT); + break; + case 4: + int openScaleUserId = OpenScale.getInstance().getSelectedScaleUserId(); + int soehnleUserIndex = getSoehnleUserIndex(openScaleUserId); + + if (soehnleUserIndex == -1) { + // create new user + Timber.d("create new Soehnle scale user"); + writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_CONTROL_POINT, new byte[]{(byte)0x01, (byte)0x00, (byte)0x00}); + } else { + // select user + Timber.d("select Soehnle scale user with index " + soehnleUserIndex); + writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_CONTROL_POINT, new byte[]{(byte) 0x02, (byte) soehnleUserIndex, (byte) 0x00, (byte) 0x00}); + } + break; + case 5: + // set age + writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_AGE, new byte[]{(byte)OpenScale.getInstance().getSelectedScaleUser().getAge()}); + break; + case 6: + // set gender + writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_GENDER, new byte[]{OpenScale.getInstance().getSelectedScaleUser().getGender().isMale() ? (byte)0x00 : (byte)0x01}); + break; + case 7: + // set height + writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_HEIGHT, Converters.toInt16Le((int)OpenScale.getInstance().getSelectedScaleUser().getBodyHeight())); + break; + case 8: + setNotificationOn(WEIGHT_CUSTOM_SERVICE, WEIGHT_CUSTOM_A_CHARACTERISTIC); + setNotificationOn(WEIGHT_CUSTOM_SERVICE, WEIGHT_CUSTOM_B_CHARACTERISTIC); + //writeBytes(WEIGHT_CUSTOM_SERVICE, WEIGHT_CUSTOM_CMD_CHARACTERISTIC, new byte[] {(byte)0x0c, (byte)0xff}); + break; + case 9: + for (int i=1; i<8; i++) { + // get history data for soehnle user index i + writeBytes(WEIGHT_CUSTOM_SERVICE, WEIGHT_CUSTOM_CMD_CHARACTERISTIC, new byte[]{(byte) 0x09, (byte) i}); + } + break; + default: + return false; + } + + return true; + } + + @Override + public void onBluetoothNotify(UUID characteristic, byte[] value) { + Timber.d("on bluetooth notify change " + byteInHex(value) + " on " + characteristic.toString()); + + if (value == null) { + return; + } + + if (characteristic.equals(WEIGHT_CUSTOM_A_CHARACTERISTIC) && value.length == 15) { + if (value[0] == (byte) 0x09) { + handleWeightMeasurement(value); + } + } else if (characteristic.equals(BluetoothGattUuid.CHARACTERISTIC_USER_CONTROL_POINT)) { + handleUserControlPoint(value); + } else if (characteristic.equals(BluetoothGattUuid.CHARACTERISTIC_BATTERY_LEVEL)) { + int batteryLevel = value[0]; + + Timber.d("Soehnle scale battery level is " + batteryLevel); + if (batteryLevel <= 10) { + sendMessage(R.string.info_scale_low_battery, batteryLevel); + } + } + } + + private void handleUserControlPoint(byte[] value) { + if (value[0] == (byte)0x20) { + int cmd = value[1]; + + if (cmd == (byte)0x01) { // user create + int userId = OpenScale.getInstance().getSelectedScaleUserId(); + int success = value[2]; + int soehnleUserIndex = value[3]; + + if (success == (byte)0x01) { + Timber.d("User control point index is " + soehnleUserIndex + " for user id " + userId); + + prefs.edit().putInt("userScaleIndex" + soehnleUserIndex, userId).apply(); + sendMessage(R.string.info_step_on_scale_for_reference, 0); + } else { + Timber.e("Error creating new Sohnle user"); + } + } + else if (cmd == (byte)0x02) { // user select + int success = value[2]; + + if (success != (byte)0x01) { + Timber.e("Error selecting Soehnle user"); + + invokeScaleFactoryReset(); + jumpNextToStepNr(0); + } + } + } + } + + private int getSoehnleUserIndex(int openScaleUserId) { + for (int i= 1; i<8; i++) { + int prefOpenScaleUserId = prefs.getInt("userScaleIndex"+i, -1); + + if (openScaleUserId == prefOpenScaleUserId) { + return i; + } + } + + return -1; + } + + private void invokeScaleFactoryReset() { + Timber.d("Do a factory reset on Soehnle scale to swipe old users"); + // factory reset + writeBytes(WEIGHT_CUSTOM_SERVICE, WEIGHT_CUSTOM_CMD_CHARACTERISTIC, new byte[]{(byte) 0x0b, (byte) 0xff}); + + for (int i= 1; i<8; i++) { + prefs.edit().putInt("userScaleIndex" + i, -1).apply(); + } + } + + private void handleWeightMeasurement(byte[] value) { + float weight = Converters.fromUnsignedInt16Be(value, 9) / 10.0f; // kg + int soehnleUserIndex = (int) value[1]; + final int year = Converters.fromUnsignedInt16Be(value, 2); + final int month = (int) value[4]; + final int day = (int) value[5]; + final int hours = (int) value[6]; + final int min = (int) value[7]; + final int sec = (int) value[8]; + + final int imp5 = Converters.fromUnsignedInt16Be(value, 11); + final int imp50 = Converters.fromUnsignedInt16Be(value, 13); + + String date_string = year + "/" + month + "/" + day + "/" + hours + "/" + min; + Date date_time = new Date(); + try { + date_time = new SimpleDateFormat("yyyy/MM/dd/HH/mm").parse(date_string); + } catch (ParseException e) { + Timber.e("parse error " + e.getMessage()); + } + + final ScaleUser scaleUser = OpenScale.getInstance().getSelectedScaleUser(); + + int activityLevel = 0; + + switch (scaleUser.getActivityLevel()) { + case SEDENTARY: + activityLevel = 0; + break; + case MILD: + activityLevel = 1; + break; + case MODERATE: + activityLevel = 2; + break; + case HEAVY: + activityLevel = 4; + break; + case EXTREME: + activityLevel = 5; + break; + } + + int openScaleUserId = prefs.getInt("userScaleIndex"+soehnleUserIndex, -1); + + if (openScaleUserId == -1) { + Timber.e("Unknown Soehnle user index " + soehnleUserIndex); + } else { + SoehnleLib soehnleLib = new SoehnleLib(scaleUser.getGender().isMale(), scaleUser.getAge(), scaleUser.getBodyHeight(), activityLevel); + + ScaleMeasurement scaleMeasurement = new ScaleMeasurement(); + + scaleMeasurement.setUserId(openScaleUserId); + scaleMeasurement.setWeight(weight); + scaleMeasurement.setDateTime(date_time); + scaleMeasurement.setWater(soehnleLib.getWater(weight, imp50)); + scaleMeasurement.setFat(soehnleLib.getFat(weight, imp50)); + scaleMeasurement.setMuscle(soehnleLib.getMuscle(weight, imp50, imp5)); + + addScaleMeasurement(scaleMeasurement); + } + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/SoehnleLib.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/SoehnleLib.java new file mode 100644 index 00000000..7bc9f0a2 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/lib/SoehnleLib.java @@ -0,0 +1,147 @@ +/* Copyright (C) 2019 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 + */ +package com.health.openscale.core.bluetooth.lib; + +public class SoehnleLib { + private boolean isMale; // male = 1; female = 0 + private int age; + private float height; + private int activityLevel; + + public SoehnleLib(boolean isMale, int age, float height, int activityLevel) { + this.isMale = isMale; + this.age = age; + this.height = height; + this.activityLevel = activityLevel; + } + + public float getFat(final float weight, final float imp50) { // in % + float activityCorrFac = 0.0f; + + switch (activityLevel) { + case 4: { + if (isMale) { + activityCorrFac = 2.5f; + } + else { + activityCorrFac = 2.3f; + } + break; + } + case 5: { + if (isMale) { + activityCorrFac = 4.3f; + } + else { + activityCorrFac = 4.1f; + } + break; + } + } + + float sexCorrFac; + float activitySexDiv; + + if (isMale) { + sexCorrFac = 0.250f; + activitySexDiv = 65.5f; + } + else { + sexCorrFac = 0.214f; + activitySexDiv = 55.1f; + } + + return 1.847f * weight * 10000.0f / (height * height) + sexCorrFac * age + 0.062f * imp50 - (activitySexDiv - activityCorrFac); + } + + public float computeBodyMassIndex(final float weight) { + return 10000.0f * weight / (height * height); + } + + public float getWater(final float weight, final float imp50) { // in % + float activityCorrFac = 0.0f; + + switch (activityLevel) { + case 1: + case 2: + case 3: { + if (isMale) { + activityCorrFac = 2.83f; + } + else { + activityCorrFac = 0.0f; + } + break; + } + case 4: { + if (isMale) { + activityCorrFac = 3.93f; + } + else { + activityCorrFac = 0.4f; + } + break; + } + case 5: { + if (isMale) { + activityCorrFac = 5.33f; + } + else { + activityCorrFac = 1.4f; + } + break; + } + } + return (0.3674f * height * height / imp50 + 0.17530f * weight - 0.11f * age + (6.53f + activityCorrFac)) / weight * 100.0f; + } + + public float getMuscle(final float weight, final float imp50, final float imp5) { // in % + float activityCorrFac = 0.0f; + + switch (activityLevel) { + case 1: + case 2: + case 3: { + if (isMale) { + activityCorrFac = 3.6224f; + } + else { + activityCorrFac = 0.0f; + } + break; + } + case 4: { + if (isMale) { + activityCorrFac = 4.3904f; + } + else { + activityCorrFac = 0.0f; + } + break; + } + case 5: { + if (isMale) { + activityCorrFac = 5.4144f; + } + else { + activityCorrFac = 1.664f; + } + break; + } + } + return ((0.47027f / imp50 - 0.24196f / imp5) * height * height + 0.13796f * weight - 0.1152f * age + (5.12f + activityCorrFac)) / weight * 100.0f; + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/datatypes/ScaleMeasurement.java b/android_app/app/src/main/java/com/health/openscale/core/datatypes/ScaleMeasurement.java index 4ac71df2..63076263 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/datatypes/ScaleMeasurement.java +++ b/android_app/app/src/main/java/com/health/openscale/core/datatypes/ScaleMeasurement.java @@ -46,7 +46,8 @@ public class ScaleMeasurement implements Cloneable { private int userId; @ColumnInfo(name = "enabled") private boolean enabled; - @CsvColumn(converterClass = CsvHelper.DateTimeConverter.class, format ="yyyy-MM-dd HH:mm", mustNotBeBlank = true) @ColumnInfo(name = "datetime") + @CsvColumn(converterClass = CsvHelper.DateTimeConverter.class, format ="yyyy-MM-dd HH:mm", mustNotBeBlank = true) + @ColumnInfo(name = "datetime") private Date dateTime; @CsvColumn(mustNotBeBlank = true) @ColumnInfo(name = "weight") diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index 0d5670b9..6062e1e1 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -83,6 +83,7 @@ enabled disabled not available + Low battery level (%d\%%), please change the scale batteries Connecting to: Lost Bluetooth connection No Bluetooth device found @@ -91,6 +92,7 @@ Connection established Initialize Bluetooth device Unexpected Bluetooth error + Cannot connect to scale, please make sure that the scale is turned on Bluetooth connection closed %1$.2f%2$s [%3$s] to %4$s added measurement with the same date and time already exist