mirror of
https://github.com/oliexdev/openScale.git
synced 2025-08-30 19:49:59 +02:00
Refactor the Beurer/Sanitas command handling
* Fetches saved measurements for all local users when connecting. * Supports user names shorter than 3 characters. * Introduces a new "paused" state for the state machine when waiting for response from the scale. I.e. a request to get a list of users will trigger a number of notifications from the scale. The state machine is now paused until all users have been received, simplifying the code. Should also fix some problems reported in #319.
This commit is contained in:
@@ -29,13 +29,12 @@ import com.health.openscale.core.datatypes.ScaleMeasurement;
|
||||
import com.health.openscale.core.datatypes.ScaleUser;
|
||||
import com.health.openscale.core.utils.Converters;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.text.ParseException;
|
||||
import java.text.Normalizer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.TreeSet;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
|
||||
import timber.log.Timber;
|
||||
@@ -47,15 +46,98 @@ public class BluetoothBeurerSanitas extends BluetoothCommunication {
|
||||
private static final UUID CUSTOM_CHARACTERISTIC_WEIGHT = BluetoothGattUuid.fromShortCode(0xffe1);
|
||||
|
||||
private final DeviceType deviceType;
|
||||
private int startByte;
|
||||
private int currentScaleUserId;
|
||||
private int countRegisteredScaleUsers;
|
||||
private TreeSet<Integer> seenUsers;
|
||||
private int maxRegisteredScaleUser;
|
||||
private ByteArrayOutputStream receivedScaleData;
|
||||
private byte startByte;
|
||||
|
||||
private int getAlternativeStartByte(int id) {
|
||||
return (startByte & 0xF0) | (id & 0x0F);
|
||||
private class RemoteUser {
|
||||
final public long remoteUserId;
|
||||
final public String name;
|
||||
final public int year;
|
||||
|
||||
public int localUserId = -1;
|
||||
public boolean isNew = false;
|
||||
|
||||
RemoteUser(long uid, String name, int year) {
|
||||
this.remoteUserId = uid;
|
||||
this.name = name;
|
||||
this.year = year;
|
||||
}
|
||||
}
|
||||
|
||||
private ArrayList<RemoteUser> remoteUsers = new ArrayList<>();
|
||||
private RemoteUser currentRemoteUser;
|
||||
private byte[] measurementData;
|
||||
|
||||
private final int ID_START_NIBBLE_INIT = 6;
|
||||
private final int ID_START_NIBBLE_CMD = 7;
|
||||
private final int ID_START_NIBBLE_SET_TIME = 9;
|
||||
private final int ID_START_NIBBLE_DISCONNECT = 10;
|
||||
|
||||
private final byte CMD_SET_UNIT = (byte)0x4d;
|
||||
private final byte CMD_SCALE_STATUS = (byte)0x4f;
|
||||
|
||||
private final byte CMD_USER_ADD = (byte)0x31;
|
||||
private final byte CMD_USER_DELETE = (byte)0x32;
|
||||
private final byte CMD_USER_LIST = (byte)0x33;
|
||||
private final byte CMD_USER_INFO = (byte)0x34;
|
||||
private final byte CMD_USER_UPDATE = (byte)0x35;
|
||||
private final byte CMD_USER_DETAILS = (byte)0x36;
|
||||
|
||||
private final byte CMD_DO_MEASUREMENT = (byte)0x40;
|
||||
private final byte CMD_GET_SAVED_MEASUREMENTS = (byte)0x41;
|
||||
private final byte CMD_SAVED_MEASUREMENT = (byte)0x42;
|
||||
private final byte CMD_DELETE_SAVED_MEASUREMENTS = (byte)0x43;
|
||||
|
||||
private final byte CMD_GET_UNKNOWN_MEASUREMENTS = (byte)0x46;
|
||||
private final byte CMD_UNKNOWN_MEASUREMENT_INFO = (byte)0x47;
|
||||
private final byte CMD_ASSIGN_UNKNOWN_MEASUREMENT = (byte)0x4b;
|
||||
private final byte CMD_UNKNOWN_MEASUREMENT = (byte)0x4c;
|
||||
private final byte CMD_DELETE_UNKNOWN_MEASUREMENT = (byte)0x49;
|
||||
|
||||
private final byte CMD_WEIGHT_MEASUREMENT = (byte)0x58;
|
||||
private final byte CMD_MEASUREMENT = (byte)0x59;
|
||||
|
||||
private final byte CMD_SCALE_ACK = (byte)0xf0;
|
||||
private final byte CMD_APP_ACK = (byte)0xf1;
|
||||
|
||||
private byte getAlternativeStartByte(int startNibble) {
|
||||
return (byte) ((startByte & 0xF0) | startNibble);
|
||||
}
|
||||
|
||||
private long decodeUserId(byte[] data, int offset) {
|
||||
long high = Converters.fromUnsignedInt32Be(data, offset);
|
||||
long low = Converters.fromUnsignedInt32Be(data, offset + 4);
|
||||
return (high << 32) | low;
|
||||
}
|
||||
|
||||
private byte[] encodeUserId(RemoteUser remoteUser) {
|
||||
long uid = remoteUser != null ? remoteUser.remoteUserId : 0;
|
||||
byte[] data = new byte[8];
|
||||
Converters.toInt32Be(data, 0, uid >> 32);
|
||||
Converters.toInt32Be(data, 4, uid & 0xFFFFFFFF);
|
||||
return data;
|
||||
}
|
||||
|
||||
private String decodeString(byte[] data, int offset, int maxLength) {
|
||||
int length = 0;
|
||||
for (; length < maxLength; ++length) {
|
||||
if (data[offset + length] == 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return new String(data, offset, length);
|
||||
}
|
||||
|
||||
private String normalizeString(String input) {
|
||||
String normalized = Normalizer.normalize(input, Normalizer.Form.NFD);
|
||||
return normalized.replaceAll("[^A-Za-z0-9]", "");
|
||||
}
|
||||
|
||||
private String convertUserNameToScale(ScaleUser user) {
|
||||
String normalized = normalizeString(user.getUserName());
|
||||
if (normalized.isEmpty()) {
|
||||
return String.valueOf(user.getId());
|
||||
}
|
||||
return normalized.toUpperCase(Locale.US);
|
||||
}
|
||||
|
||||
public BluetoothBeurerSanitas(Context context, DeviceType deviceType) {
|
||||
@@ -64,11 +146,11 @@ public class BluetoothBeurerSanitas extends BluetoothCommunication {
|
||||
this.deviceType = deviceType;
|
||||
switch (deviceType) {
|
||||
case BEURER_BF700_800_RT_LIBRA:
|
||||
startByte = 0xf7;
|
||||
startByte = (byte) (0xf0 | ID_START_NIBBLE_CMD);
|
||||
break;
|
||||
case BEURER_BF710:
|
||||
case SANITAS_SBF70_70:
|
||||
startByte = 0xe7;
|
||||
startByte = (byte) (0xe0 | ID_START_NIBBLE_CMD);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -92,92 +174,77 @@ public class BluetoothBeurerSanitas extends BluetoothCommunication {
|
||||
|
||||
switch (stateNr) {
|
||||
case 0:
|
||||
// Initialize data
|
||||
currentScaleUserId = -1;
|
||||
countRegisteredScaleUsers = -1;
|
||||
maxRegisteredScaleUser = -1;
|
||||
seenUsers = new TreeSet<>();
|
||||
|
||||
// Setup notification
|
||||
setNotificationOn(CUSTOM_SERVICE_1, CUSTOM_CHARACTERISTIC_WEIGHT,
|
||||
BluetoothGattUuid.DESCRIPTOR_CLIENT_CHARACTERISTIC_CONFIGURATION);
|
||||
break;
|
||||
case 1:
|
||||
// Say "Hello" to the scale
|
||||
writeBytes(new byte[]{(byte) getAlternativeStartByte(6), (byte) 0x01});
|
||||
// Say "Hello" to the scale and wait for ack
|
||||
sendAlternativeStartCode(ID_START_NIBBLE_INIT, (byte) 0x01);
|
||||
pauseBtStateMachine();
|
||||
break;
|
||||
case 2:
|
||||
// Wait for "Hello" ack from scale
|
||||
// Update time on the scale (no ack)
|
||||
long unixTime = System.currentTimeMillis() / 1000L;
|
||||
sendAlternativeStartCode(ID_START_NIBBLE_SET_TIME, Converters.toInt32Be(unixTime));
|
||||
break;
|
||||
case 3:
|
||||
// Update timestamp of the scale
|
||||
updateDateTime();
|
||||
// Request scale status and wait for ack
|
||||
sendCommand(CMD_SCALE_STATUS, encodeUserId(null));
|
||||
pauseBtStateMachine();
|
||||
break;
|
||||
case 4:
|
||||
// Set measurement unit
|
||||
setUnitCommand();
|
||||
// Request list of all users and wait until all have been received
|
||||
sendCommand(CMD_USER_LIST);
|
||||
pauseBtStateMachine();
|
||||
break;
|
||||
case 5:
|
||||
// Wait for "unit" ack from scale
|
||||
break;
|
||||
case 6:
|
||||
// Request general user information
|
||||
writeBytes(new byte[]{(byte) startByte, (byte) 0x33});
|
||||
break;
|
||||
case 7:
|
||||
// Wait for ack of all users
|
||||
if (seenUsers.size() < countRegisteredScaleUsers || (countRegisteredScaleUsers == -1)) {
|
||||
// Request this state again
|
||||
setNextCmd(stateNr);
|
||||
break;
|
||||
}
|
||||
// If currentRemoteUser is null, indexOf returns -1 and index will be 0
|
||||
int index = remoteUsers.indexOf(currentRemoteUser) + 1;
|
||||
currentRemoteUser = null;
|
||||
|
||||
// Got all user acks
|
||||
|
||||
// Check if not found/unknown
|
||||
if (currentScaleUserId == 0) {
|
||||
// Unknown user, request creation of new user
|
||||
if (countRegisteredScaleUsers == maxRegisteredScaleUser) {
|
||||
setBtMachineState(BT_MACHINE_STATE.BT_CLEANUP_STATE);
|
||||
Timber.d("Cannot create additional scale user");
|
||||
sendMessage(R.string.error_max_scale_users, 0);
|
||||
// Find the next remote user that exists locally
|
||||
for (; index < remoteUsers.size(); ++index) {
|
||||
if (remoteUsers.get(index).localUserId != -1) {
|
||||
currentRemoteUser = remoteUsers.get(index);
|
||||
break;
|
||||
}
|
||||
|
||||
// Request creation of user
|
||||
final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser();
|
||||
|
||||
// We can only use up to 3 characters and have to handle them uppercase
|
||||
int maxIdx = Math.min(3, selectedUser.getUserName().length());
|
||||
byte[] nick = selectedUser.getUserName().toUpperCase().substring(0, maxIdx).getBytes();
|
||||
|
||||
byte activity = (byte)(selectedUser.getActivityLevel().toInt() + 1); // activity level: 1 - 5
|
||||
Timber.d("Create User: %s", selectedUser.getUserName());
|
||||
|
||||
writeBytes(new byte[]{
|
||||
(byte) startByte, (byte) 0x31, (byte) 0x0, (byte) 0x0, (byte) 0x0,
|
||||
(byte) 0x0, (byte) 0x0, (byte) 0x0, (byte) 0x0,
|
||||
(byte) (seenUsers.size() > 0 ? Collections.max(seenUsers) + 1 : 101),
|
||||
nick[0], nick[1], nick[2],
|
||||
(byte) selectedUser.getBirthday().getYear(),
|
||||
(byte) selectedUser.getBirthday().getMonth(),
|
||||
(byte) selectedUser.getBirthday().getDate(),
|
||||
(byte) selectedUser.getBodyHeight(),
|
||||
(byte) (((selectedUser.getGender().isMale() ? 1 : 0) << 7) | activity)
|
||||
});
|
||||
} else {
|
||||
// Get existing user information
|
||||
Timber.d("Request getUserInfo %d", currentScaleUserId);
|
||||
writeBytes(new byte[]{
|
||||
(byte) startByte, (byte) 0x36, (byte) 0x0, (byte) 0x0, (byte) 0x0,
|
||||
(byte) 0x0, (byte) 0x0, (byte) 0x0, (byte) 0x0, (byte) currentScaleUserId
|
||||
});
|
||||
|
||||
}
|
||||
Timber.d("scaleuserid: %d, registered users: %d, extracted users: %d",
|
||||
currentScaleUserId, countRegisteredScaleUsers, seenUsers.size());
|
||||
|
||||
// Fetch saved measurements
|
||||
if (currentRemoteUser != null) {
|
||||
Timber.d("Request saved measurements for %s", currentRemoteUser.name);
|
||||
sendCommand(CMD_GET_SAVED_MEASUREMENTS, encodeUserId(currentRemoteUser));
|
||||
|
||||
// Return to this state until all users have been processed
|
||||
setNextCmd(stateNr);
|
||||
pauseBtStateMachine();
|
||||
}
|
||||
else {
|
||||
postHandleRequest();
|
||||
}
|
||||
break;
|
||||
case 8:
|
||||
case 6:
|
||||
// Create a remote user for selected openScale user if needed
|
||||
currentRemoteUser = null;
|
||||
final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser();
|
||||
for (RemoteUser remoteUser : remoteUsers) {
|
||||
if (remoteUser.localUserId == selectedUser.getId()) {
|
||||
currentRemoteUser = remoteUser;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (currentRemoteUser == null) {
|
||||
createRemoteUser(selectedUser);
|
||||
pauseBtStateMachine();
|
||||
}
|
||||
else {
|
||||
postHandleRequest();
|
||||
}
|
||||
break;
|
||||
case 7:
|
||||
sendCommand(CMD_USER_DETAILS, encodeUserId(currentRemoteUser));
|
||||
pauseBtStateMachine();
|
||||
break;
|
||||
default:
|
||||
// Finish init if everything is done
|
||||
@@ -189,24 +256,17 @@ public class BluetoothBeurerSanitas extends BluetoothCommunication {
|
||||
|
||||
@Override
|
||||
protected boolean nextBluetoothCmd(int stateNr) {
|
||||
|
||||
switch (stateNr) {
|
||||
case 0:
|
||||
// If no specific user selected
|
||||
if (currentScaleUserId == 0)
|
||||
break;
|
||||
|
||||
Timber.d("Request Saved User Measurements");
|
||||
writeBytes(new byte[]{
|
||||
(byte) startByte, (byte) 0x41, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, (byte) currentScaleUserId
|
||||
});
|
||||
|
||||
if (!currentRemoteUser.isNew) {
|
||||
sendCommand(CMD_DO_MEASUREMENT, encodeUserId(currentRemoteUser));
|
||||
pauseBtStateMachine();
|
||||
}
|
||||
else {
|
||||
postHandleRequest();
|
||||
}
|
||||
break;
|
||||
case 1:
|
||||
// Wait for user measurements to be received
|
||||
setNextCmd(stateNr);
|
||||
break;
|
||||
case 2:
|
||||
setBtMachineState(BT_MACHINE_STATE.BT_CLEANUP_STATE);
|
||||
break;
|
||||
default:
|
||||
@@ -221,7 +281,7 @@ public class BluetoothBeurerSanitas extends BluetoothCommunication {
|
||||
switch (stateNr) {
|
||||
case 0:
|
||||
// Force disconnect
|
||||
writeBytes(new byte[]{(byte) 0xea, (byte) 0x02});
|
||||
sendAlternativeStartCode(ID_START_NIBBLE_DISCONNECT, (byte) 0x02);
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
@@ -232,264 +292,271 @@ public class BluetoothBeurerSanitas extends BluetoothCommunication {
|
||||
@Override
|
||||
public void onBluetoothDataChange(BluetoothGatt bluetoothGatt, BluetoothGattCharacteristic gattCharacteristic) {
|
||||
byte[] data = gattCharacteristic.getValue();
|
||||
if (data.length == 0) {
|
||||
if (data == null || data.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((data[0] & 0xFF) == getAlternativeStartByte(6) && (data[1] & 0xFF) == 0x00) {
|
||||
Timber.d("ACK Scale is ready");
|
||||
nextMachineStateStep();
|
||||
if (data[0] == getAlternativeStartByte(ID_START_NIBBLE_INIT)) {
|
||||
Timber.d("Got init ack from scale; scale is ready");
|
||||
resumeBtStateMachine();
|
||||
return;
|
||||
}
|
||||
|
||||
if ((data[0] & 0xFF) == startByte && (data[1] & 0xFF) == 0xf0 && data[2] == 0x4d) {
|
||||
Timber.d("ACK Unit set");
|
||||
nextMachineStateStep();
|
||||
if (data[0] != startByte) {
|
||||
Timber.e("Got unknown start byte 0x%02x", data[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((data[0] & 0xFF) == startByte && (data[1] & 0xFF) == 0xf0 && data[2] == 0x33) {
|
||||
Timber.d("ACK Got general user information");
|
||||
|
||||
int count = (byte) (data[4] & 0xFF);
|
||||
int maxUsers = (byte) (data[5] & 0xFF);
|
||||
Timber.d("Count: %d, maxUsers: %d", count, maxUsers);
|
||||
|
||||
countRegisteredScaleUsers = count;
|
||||
// Check if any scale user is registered
|
||||
if (count == 0) {
|
||||
currentScaleUserId = 0; // Unknown user
|
||||
try {
|
||||
switch (data[1]) {
|
||||
case CMD_USER_INFO:
|
||||
processUserInfo(data);
|
||||
break;
|
||||
case CMD_SAVED_MEASUREMENT:
|
||||
processSavedMeasurement(data);
|
||||
break;
|
||||
case CMD_WEIGHT_MEASUREMENT:
|
||||
processWeightMeasurement(data);
|
||||
break;
|
||||
case CMD_MEASUREMENT:
|
||||
processMeasurement(data);
|
||||
break;
|
||||
case CMD_SCALE_ACK:
|
||||
processScaleAck(data);
|
||||
break;
|
||||
default:
|
||||
Timber.d("Unknown command 0x%02x", data[1]);
|
||||
break;
|
||||
}
|
||||
maxRegisteredScaleUser = maxUsers;
|
||||
|
||||
nextMachineStateStep();
|
||||
return;
|
||||
}
|
||||
|
||||
if ((data[0] & 0xFF) == startByte && (data[1] & 0xFF) == 0x34) {
|
||||
Timber.d("Ack Get UUIDSs List of Users");
|
||||
|
||||
byte currentUserMax = (byte) (data[2] & 0xFF);
|
||||
byte currentUserID = (byte) (data[3] & 0xFF);
|
||||
byte userUuid = (byte) (data[11] & 0xFF);
|
||||
String name = new String(data, 12, 3);
|
||||
int year = (byte) (data[15] & 0xFF);
|
||||
|
||||
final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser();
|
||||
|
||||
// Check if we found the currently selected user
|
||||
if (selectedUser.getUserName().toLowerCase().startsWith(name.toLowerCase()) &&
|
||||
selectedUser.getBirthday().getYear() == year) {
|
||||
// Found user
|
||||
currentScaleUserId = userUuid;
|
||||
}
|
||||
|
||||
// Remember this uuid from the scale
|
||||
if (seenUsers.add((int) userUuid)) {
|
||||
if (currentScaleUserId == -1 && seenUsers.size() == countRegisteredScaleUsers) {
|
||||
// We have seen all users: user is unknown
|
||||
currentScaleUserId = 0;
|
||||
}
|
||||
Timber.d("Send ack gotUser");
|
||||
writeBytes(new byte[]{
|
||||
(byte) startByte, (byte) 0xf1, (byte) 0x34, currentUserMax,
|
||||
currentUserID
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
catch (IndexOutOfBoundsException|NullPointerException e) {
|
||||
Timber.e(e);
|
||||
}
|
||||
|
||||
if ((data[0] & 0xFF) == startByte && (data[1] & 0xFF) == 0xF0 && (data[2] & 0xFF) == 0x36) {
|
||||
Timber.d("Ack Get User Info Initials");
|
||||
String name = new String(data, 4, 3);
|
||||
byte year = (byte) (data[7] & 0xFF);
|
||||
byte month = (byte) (data[8] & 0xFF);
|
||||
byte day = (byte) (data[9] & 0xFF);
|
||||
|
||||
int height = (data[10] & 0xFF);
|
||||
boolean male = (data[11] & 0xF0) != 0;
|
||||
byte activity = (byte) (data[11] & 0x0F);
|
||||
|
||||
Timber.d("Name: %s, YY-MM-DD: %d-%d-%d, Height: %d, Sex: %s, activity: %d",
|
||||
name, year, month, day, height, male ? "male" : "female", activity);
|
||||
|
||||
// Get scale status for user
|
||||
writeBytes(new byte[]{
|
||||
(byte) startByte, (byte) 0x4f, (byte) 0x0, (byte) 0x0, (byte) 0x0, (byte) 0x0,
|
||||
(byte) 0x0, (byte) 0x0, (byte) 0x0, (byte) currentScaleUserId
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ((data[0] & 0xFF) == startByte && (data[1] & 0xFF) == 0xf0 && (data[2] & 0xFF) == 0x4F) {
|
||||
Timber.d("Ack Get scale status");
|
||||
|
||||
int unknown = data[3];
|
||||
int batteryLevel = (data[4] & 0xFF);
|
||||
float weightThreshold = (data[5] & 0xFF) / 10f;
|
||||
float bodyFatThreshold = (data[6] & 0xFF) / 10f;
|
||||
int unit = data[7]; // 1 kg, 2 lb (pounds), 4 st stone
|
||||
boolean userExists = (data[8] == 0);
|
||||
boolean userReferWeightExists = (data[9] == 0);
|
||||
boolean userMeasurementExist = (data[10] == 0);
|
||||
int scaleVersion = data[11];
|
||||
|
||||
Timber.d("BatteryLevel: %d, weightThreshold: %.2f, BodyFatThreshold: %.2f,"
|
||||
+ " Unit: %d, userExists: %b, UserReference Weight Exists: %b,"
|
||||
+ " UserMeasurementExists: %b, scaleVersion: %d",
|
||||
batteryLevel, weightThreshold, bodyFatThreshold, unit, userExists,
|
||||
userReferWeightExists, userMeasurementExist, scaleVersion);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((data[0] & 0xFF) == startByte && (data[1] & 0xFF) == 0xf0 && data[2] == 0x31) {
|
||||
Timber.d("Acknowledge creation of user");
|
||||
|
||||
// Indicate user to step on scale
|
||||
sendMessage(R.string.info_step_on_scale, 0);
|
||||
|
||||
// Request basement measurement
|
||||
writeBytes(new byte[]{
|
||||
(byte) startByte, 0x40, 0, 0, 0, 0, 0, 0, 0,
|
||||
(byte) (seenUsers.size() > 0 ? Collections.max(seenUsers) + 1 : 101)
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if ((data[0] & 0xFF) == startByte && (data[1] & 0xFF) == 0xf0 && (data[2] & 0xFF) == 0x41) {
|
||||
Timber.d("Will start to receive measurements User Specific");
|
||||
|
||||
byte nr_measurements = data[3];
|
||||
|
||||
Timber.d("New measurements: %d", nr_measurements / 2);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((data[0] & 0xFF) == startByte && (data[1] & 0xFF) == 0x42) {
|
||||
Timber.d("Specific measurement User specific");
|
||||
|
||||
// Measurements are split into two parts
|
||||
|
||||
int max_items = data[2] & 0xFF;
|
||||
int current_item = data[3] & 0xFF;
|
||||
|
||||
// Received even part
|
||||
if (current_item % 2 == 1) {
|
||||
receivedScaleData = new ByteArrayOutputStream();
|
||||
}
|
||||
|
||||
try {
|
||||
receivedScaleData.write(Arrays.copyOfRange(data, 4, data.length));
|
||||
} catch (IOException e) {
|
||||
Timber.e(e, "Failed to copy user specific data");
|
||||
}
|
||||
|
||||
// Send acknowledgement
|
||||
writeBytes(new byte[]{
|
||||
(byte) startByte, (byte) 0xf1, (byte) 0x42, (byte) (data[2] & 0xFF),
|
||||
(byte) (data[3] & 0xFF)
|
||||
});
|
||||
|
||||
if (current_item % 2 == 0) {
|
||||
try {
|
||||
ScaleMeasurement parsedData = parseScaleData(receivedScaleData.toByteArray());
|
||||
addScaleData(parsedData);
|
||||
} catch (ParseException e) {
|
||||
Timber.d(e, "Could not parse byte array: %s", byteInHex(receivedScaleData.toByteArray()));
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
if (current_item == max_items) {
|
||||
// finish and delete
|
||||
deleteScaleData();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ((data[0] & 0xFF) == startByte && (data[1] & 0xFF) == 0x58) {
|
||||
float weight = getKiloGram(data, 3);
|
||||
if ((data[2] & 0xFF) != 0x00) {
|
||||
// temporary value;
|
||||
Timber.d("Active measurement, weight: %.2f", weight);
|
||||
sendMessage(R.string.info_measuring, weight);
|
||||
return;
|
||||
}
|
||||
|
||||
Timber.i("Active measurement, stable weight: %.2f", weight);
|
||||
|
||||
writeBytes(new byte[]{
|
||||
(byte) startByte, (byte) 0xf1, (byte) (data[1] & 0xFF),
|
||||
(byte) (data[2] & 0xFF), (byte) (data[3] & 0xFF),
|
||||
});
|
||||
|
||||
if (currentScaleUserId == 0) {
|
||||
Timber.i("Initial weight set; disconnecting...");
|
||||
setBtMachineState(BT_MACHINE_STATE.BT_CLEANUP_STATE);
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ((data[0] & 0xFF) == startByte && (data[1] & 0xFF) == 0x59) {
|
||||
// Get stable measurement results
|
||||
Timber.d("Get measurement data %d", (int) data[3]);
|
||||
|
||||
int max_items = (data[2] & 0xFF);
|
||||
int current_item = (data[3] & 0xFF);
|
||||
|
||||
// Received first part
|
||||
if (current_item == 1) {
|
||||
receivedScaleData = new ByteArrayOutputStream();
|
||||
} else {
|
||||
try {
|
||||
receivedScaleData.write(Arrays.copyOfRange(data, 4, data.length));
|
||||
} catch (IOException e) {
|
||||
Timber.e(e, "Failed to copy stable measurement array");
|
||||
}
|
||||
}
|
||||
|
||||
// Send ack that we got the data
|
||||
writeBytes(new byte[]{
|
||||
(byte) startByte, (byte) 0xf1,
|
||||
(byte) (data[1] & 0xFF), (byte) (data[2] & 0xFF),
|
||||
(byte) (data[3] & 0xFF),
|
||||
});
|
||||
|
||||
if (current_item == max_items) {
|
||||
// received all parts
|
||||
try {
|
||||
ScaleMeasurement parsedData = parseScaleData(receivedScaleData.toByteArray());
|
||||
addScaleData(parsedData);
|
||||
// Delete data
|
||||
deleteScaleData();
|
||||
} catch (ParseException e) {
|
||||
Timber.d(e, "Parse Exception %s", byteInHex(receivedScaleData.toByteArray()));
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ((data[0] & 0xFF) == startByte && (data[1] & 0xFF) == 0xf0 && (data[2] & 0xFF) == 0x43) {
|
||||
Timber.d("Acknowledge: Data deleted.");
|
||||
return;
|
||||
}
|
||||
|
||||
Timber.d("DataChanged - not handled: %s", byteInHex(data));
|
||||
}
|
||||
|
||||
private void deleteScaleData() {
|
||||
writeBytes(new byte[]{
|
||||
(byte) startByte, (byte) 0x43, (byte) 0x0, (byte) 0x0, (byte) 0x0,
|
||||
(byte) 0x0, (byte) 0x0, (byte) 0x0, (byte) 0x0,
|
||||
(byte) currentScaleUserId
|
||||
});
|
||||
private void processUserInfo(byte[] data) {
|
||||
final int count = data[2] & 0xFF;
|
||||
final int current = data[3] & 0xFF;
|
||||
|
||||
if (remoteUsers.size() == current - 1) {
|
||||
String name = decodeString(data, 12, 3);
|
||||
int year = 1900 + (data[15] & 0xFF);
|
||||
|
||||
remoteUsers.add(new RemoteUser(decodeUserId(data, 4), name, year));
|
||||
|
||||
Timber.d("Received user %d/%d: %s (%d)", current, count, name, year);
|
||||
}
|
||||
|
||||
sendAck(data);
|
||||
|
||||
if (current != count) {
|
||||
return;
|
||||
}
|
||||
|
||||
Calendar cal = Calendar.getInstance();
|
||||
|
||||
for (ScaleUser scaleUser : OpenScale.getInstance().getScaleUserList()) {
|
||||
final String localName = convertUserNameToScale(scaleUser);
|
||||
cal.setTime(scaleUser.getBirthday());
|
||||
final int year = cal.get(Calendar.YEAR);
|
||||
|
||||
for (RemoteUser remoteUser : remoteUsers) {
|
||||
if (localName.startsWith(remoteUser.name) && year == remoteUser.year) {
|
||||
remoteUser.localUserId = scaleUser.getId();
|
||||
Timber.d("Remote user %s (0x%x) is local user %s (%d)",
|
||||
remoteUser.name, remoteUser.remoteUserId,
|
||||
scaleUser.getUserName(), remoteUser.localUserId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All users received
|
||||
resumeBtStateMachine();
|
||||
}
|
||||
|
||||
private void processMeasurementData(byte[] data, int offset, boolean firstPart) {
|
||||
if (firstPart) {
|
||||
measurementData = Arrays.copyOfRange(data, offset, data.length);
|
||||
return;
|
||||
}
|
||||
|
||||
int oldEnd = measurementData.length;
|
||||
int toCopy = data.length - offset;
|
||||
|
||||
measurementData = Arrays.copyOf(measurementData, oldEnd + toCopy);
|
||||
System.arraycopy(data, offset, measurementData, oldEnd, toCopy);
|
||||
|
||||
addMeasurement(measurementData, currentRemoteUser.localUserId);
|
||||
measurementData = null;
|
||||
}
|
||||
|
||||
private void processSavedMeasurement(byte[] data) {
|
||||
int count = data[2] & 0xFF;
|
||||
int current = data[3] & 0xFF;
|
||||
|
||||
processMeasurementData(data, 4, current % 2 == 1);
|
||||
sendAck(data);
|
||||
|
||||
if (current == count) {
|
||||
sendCommand(CMD_DELETE_SAVED_MEASUREMENTS, encodeUserId(currentRemoteUser));
|
||||
}
|
||||
}
|
||||
|
||||
private void processWeightMeasurement(byte[] data) {
|
||||
boolean stableMeasurement = data[2] == 0;
|
||||
float weight = getKiloGram(data, 3);
|
||||
|
||||
if (!stableMeasurement) {
|
||||
Timber.d("Active measurement, weight: %.2f", weight);
|
||||
sendMessage(R.string.info_measuring, weight);
|
||||
return;
|
||||
}
|
||||
|
||||
Timber.i("Active measurement, stable weight: %.2f", weight);
|
||||
}
|
||||
|
||||
private void processMeasurement(byte[] data) {
|
||||
int count = data[2] & 0xFF;
|
||||
int current = data[3] & 0xFF;
|
||||
|
||||
if (current == 1) {
|
||||
long uid = decodeUserId(data, 5);
|
||||
currentRemoteUser = null;
|
||||
for (RemoteUser remoteUser : remoteUsers) {
|
||||
if (remoteUser.remoteUserId == uid) {
|
||||
currentRemoteUser = remoteUser;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
processMeasurementData(data, 4, current == 2);
|
||||
}
|
||||
|
||||
sendAck(data);
|
||||
|
||||
if (current == count) {
|
||||
sendCommand(CMD_DELETE_SAVED_MEASUREMENTS, encodeUserId(currentRemoteUser));
|
||||
}
|
||||
}
|
||||
|
||||
private void processScaleAck(byte[] data) {
|
||||
switch (data[2]) {
|
||||
case CMD_SCALE_STATUS:
|
||||
// data[3] != 0 if an invalid user id is given to the command,
|
||||
// but it still provides some useful information (e.g. current unit).
|
||||
final int batteryLevel = data[4] & 0xFF;
|
||||
final float weightThreshold = (data[5] & 0xFF) / 10f;
|
||||
final float bodyFatThreshold = (data[6] & 0xFF) / 10f;
|
||||
final int currentUnit = data[7] & 0xFF;
|
||||
final boolean userExists = data[8] == 0;
|
||||
final boolean userReferWeightExists = data[9] == 0;
|
||||
final boolean userMeasurementExist = data[10] == 0;
|
||||
final int scaleVersion = data[11] & 0xFF;
|
||||
|
||||
Timber.d("Battery level: %d; threshold: weight=%.2f, body fat=%.2f;"
|
||||
+ " unit: %d; requested user: exists=%b, has reference weight=%b,"
|
||||
+ " has measurement=%b; scale version: %d",
|
||||
batteryLevel, weightThreshold, bodyFatThreshold, currentUnit, userExists,
|
||||
userReferWeightExists, userMeasurementExist, scaleVersion);
|
||||
|
||||
byte requestedUnit = (byte) currentUnit;
|
||||
ScaleUser user = OpenScale.getInstance().getSelectedScaleUser();
|
||||
switch (user.getScaleUnit()) {
|
||||
case KG:
|
||||
requestedUnit = 1;
|
||||
break;
|
||||
case LB:
|
||||
requestedUnit = 2;
|
||||
break;
|
||||
case ST:
|
||||
requestedUnit = 4;
|
||||
break;
|
||||
}
|
||||
if (requestedUnit != currentUnit) {
|
||||
Timber.d("Set scale unit to %s (%d)", user.getScaleUnit(), requestedUnit);
|
||||
sendCommand(CMD_SET_UNIT, requestedUnit);
|
||||
} else {
|
||||
resumeBtStateMachine();
|
||||
}
|
||||
break;
|
||||
|
||||
case CMD_SET_UNIT:
|
||||
if (data[3] == 0) {
|
||||
Timber.d("Scale unit successfully set");
|
||||
}
|
||||
resumeBtStateMachine();
|
||||
break;
|
||||
|
||||
case CMD_USER_LIST:
|
||||
int userCount = data[4] & 0xFF;
|
||||
int maxUserCount = data[5] & 0xFF;
|
||||
Timber.d("Have %d users (max is %d)", userCount, maxUserCount);
|
||||
if (userCount == 0) {
|
||||
resumeBtStateMachine();
|
||||
}
|
||||
// Otherwise wait for CMD_USER_INFO notifications
|
||||
break;
|
||||
|
||||
case CMD_GET_SAVED_MEASUREMENTS:
|
||||
int measurementCount = data[3] & 0xFF;
|
||||
if (measurementCount == 0) {
|
||||
resumeBtStateMachine();
|
||||
}
|
||||
// Otherwise wait for CMD_SAVED_MEASUREMENT notifications which will,
|
||||
// once all measurements have been received, trigger a call to delete them.
|
||||
// Once the ack for that is received, we resume the state machine (see below).
|
||||
break;
|
||||
|
||||
case CMD_DELETE_SAVED_MEASUREMENTS:
|
||||
if (data[3] == 0) {
|
||||
Timber.d("Saved measurements successfully deleted");
|
||||
}
|
||||
resumeBtStateMachine();
|
||||
break;
|
||||
|
||||
case CMD_USER_ADD:
|
||||
if (data[3] == 0) {
|
||||
Timber.d("New user successfully added; time to step on scale");
|
||||
sendMessage(R.string.info_step_on_scale, 0);
|
||||
remoteUsers.add(currentRemoteUser);
|
||||
sendCommand(CMD_DO_MEASUREMENT, encodeUserId(currentRemoteUser));
|
||||
break;
|
||||
}
|
||||
|
||||
Timber.d("Cannot create additional scale user (error 0x%02x)", data[3]);
|
||||
sendMessage(R.string.error_max_scale_users, 0);
|
||||
setBtMachineState(BT_MACHINE_STATE.BT_CLEANUP_STATE);
|
||||
break;
|
||||
|
||||
case CMD_DO_MEASUREMENT:
|
||||
if (data[3] == 0) {
|
||||
Timber.d("Measure command successfully received");
|
||||
}
|
||||
break;
|
||||
|
||||
case CMD_USER_DETAILS:
|
||||
if (data[3] == 0) {
|
||||
String name = decodeString(data, 4, 3);
|
||||
int year = 1900 + (data[7] & 0xFF);
|
||||
int month = 1 + (data[8] & 0xFF);
|
||||
int day = data[9] & 0xFF;
|
||||
|
||||
int height = data[10] & 0xFF;
|
||||
boolean male = (data[11] & 0xF0) != 0;
|
||||
int activity = data[11] & 0x0F;
|
||||
|
||||
Timber.d("Name: %s, Birthday: %d-%02d-%02d, Height: %d, Sex: %s, activity: %d",
|
||||
name, year, month, day, height, male ? "male" : "female", activity);
|
||||
}
|
||||
resumeBtStateMachine();
|
||||
break;
|
||||
|
||||
default:
|
||||
Timber.d("Unhandled scale ack for command 0x%02x", data[2]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private float getKiloGram(byte[] data, int offset) {
|
||||
@@ -502,11 +569,7 @@ public class BluetoothBeurerSanitas extends BluetoothCommunication {
|
||||
return Converters.fromUnsignedInt16Be(data, offset) / 10.0f;
|
||||
}
|
||||
|
||||
private ScaleMeasurement parseScaleData(byte[] data) throws ParseException {
|
||||
if (data.length != 11 + 11) {
|
||||
throw new ParseException("Parse scala data: unexpected length", 0);
|
||||
}
|
||||
|
||||
private void addMeasurement(byte[] data, int userId) {
|
||||
long timestamp = Converters.fromUnsignedInt32Be(data, 0) * 1000;
|
||||
float weight = getKiloGram(data, 4);
|
||||
int impedance = Converters.fromUnsignedInt16Be(data, 6);
|
||||
@@ -519,6 +582,7 @@ public class BluetoothBeurerSanitas extends BluetoothCommunication {
|
||||
float bmi = Converters.fromUnsignedInt16Be(data, 20) / 10.0f;
|
||||
|
||||
ScaleMeasurement receivedMeasurement = new ScaleMeasurement();
|
||||
receivedMeasurement.setUserId(userId);
|
||||
receivedMeasurement.setDateTime(new Date(timestamp));
|
||||
receivedMeasurement.setWeight(weight);
|
||||
receivedMeasurement.setFat(fat);
|
||||
@@ -526,42 +590,69 @@ public class BluetoothBeurerSanitas extends BluetoothCommunication {
|
||||
receivedMeasurement.setMuscle(muscle);
|
||||
receivedMeasurement.setBone(bone);
|
||||
|
||||
Timber.i("Measurement: %s, Impedance: %d, BMR: %d, AMR: %d, BMI: %.2f",
|
||||
receivedMeasurement, impedance, bmr, amr, bmi);
|
||||
|
||||
return receivedMeasurement;
|
||||
}
|
||||
|
||||
private void updateDateTime() {
|
||||
// Update date/time of the scale
|
||||
long unixTime = System.currentTimeMillis() / 1000L;
|
||||
byte[] unixTimeBytes = Converters.toInt32Be(unixTime);
|
||||
Timber.d("Write new Date/Time: %d (%s)", unixTime, byteInHex(unixTimeBytes));
|
||||
|
||||
writeBytes(new byte[]{(byte) getAlternativeStartByte(9),
|
||||
unixTimeBytes[0], unixTimeBytes[1], unixTimeBytes[2], unixTimeBytes[3]});
|
||||
}
|
||||
|
||||
private void setUnitCommand() {
|
||||
byte[] command = new byte[] {(byte) startByte, 0x4d, 0x00};
|
||||
final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser();
|
||||
|
||||
switch (selectedUser.getScaleUnit()) {
|
||||
case KG:
|
||||
command[2] = (byte) 0x01;
|
||||
break;
|
||||
case LB:
|
||||
command[2] = (byte) 0x02;
|
||||
break;
|
||||
case ST:
|
||||
command[2] = (byte) 0x04;
|
||||
break;
|
||||
}
|
||||
Timber.d("Setting unit %s", selectedUser.getScaleUnit());
|
||||
writeBytes(command);
|
||||
addScaleData(receivedMeasurement);
|
||||
}
|
||||
|
||||
private void writeBytes(byte[] data) {
|
||||
writeBytes(CUSTOM_SERVICE_1, CUSTOM_CHARACTERISTIC_WEIGHT, data);
|
||||
}
|
||||
|
||||
private void sendCommand(byte command, byte... parameters) {
|
||||
byte[] data = new byte[parameters.length + 2];
|
||||
data[0] = startByte;
|
||||
data[1] = command;
|
||||
|
||||
int i = 2;
|
||||
for (byte parameter : parameters) {
|
||||
data[i++] = parameter;
|
||||
}
|
||||
|
||||
writeBytes(data);
|
||||
}
|
||||
|
||||
private void sendAck(byte[] data) {
|
||||
sendCommand(CMD_APP_ACK, Arrays.copyOfRange(data, 1, 4));
|
||||
}
|
||||
|
||||
private void sendAlternativeStartCode(int id, byte... parameters) {
|
||||
byte[] data = new byte[parameters.length + 1];
|
||||
data[0] = getAlternativeStartByte(id);
|
||||
|
||||
int i = 1;
|
||||
for (byte parameter : parameters) {
|
||||
data[i++] = parameter;
|
||||
}
|
||||
|
||||
writeBytes(data);
|
||||
}
|
||||
|
||||
private void createRemoteUser(ScaleUser scaleUser) {
|
||||
Timber.d("Create user: %s", scaleUser.getUserName());
|
||||
|
||||
Calendar cal = Calendar.getInstance();
|
||||
cal.setTime(scaleUser.getBirthday());
|
||||
|
||||
// We can only use up to 3 characters (padding with 0 if needed)
|
||||
byte[] nick = Arrays.copyOf(convertUserNameToScale(scaleUser).getBytes(), 3);
|
||||
byte year = (byte) (cal.get(Calendar.YEAR) - 1900);
|
||||
byte month = (byte) cal.get(Calendar.MONTH);
|
||||
byte day = (byte) cal.get(Calendar.DAY_OF_MONTH);
|
||||
byte height = (byte) scaleUser.getBodyHeight();
|
||||
byte sex = scaleUser.getGender().isMale() ? (byte) 0x80 : 0;
|
||||
byte activity = (byte) (scaleUser.getActivityLevel().toInt() + 1); // activity level: 1 - 5
|
||||
|
||||
long maxUserId = remoteUsers.isEmpty() ? 100 : 0;
|
||||
for (RemoteUser remoteUser : remoteUsers) {
|
||||
maxUserId = Math.max(maxUserId, remoteUser.remoteUserId);
|
||||
}
|
||||
|
||||
currentRemoteUser = new RemoteUser(maxUserId + 1, new String(nick), 1900 + year);
|
||||
currentRemoteUser.localUserId = scaleUser.getId();
|
||||
currentRemoteUser.isNew = true;
|
||||
|
||||
byte[] uid = encodeUserId(currentRemoteUser);
|
||||
|
||||
sendCommand(CMD_USER_ADD, uid[0], uid[1], uid[2], uid[3], uid[4], uid[5], uid[6], uid[7],
|
||||
nick[0], nick[1], nick[2], year, month, day, height, (byte) (sex | activity));
|
||||
}
|
||||
}
|
||||
|
@@ -48,7 +48,7 @@ public abstract class BluetoothCommunication {
|
||||
BT_CONNECTION_LOST, BT_NO_DEVICE_FOUND, BT_UNEXPECTED_ERROR, BT_SCALE_MESSAGE
|
||||
}
|
||||
|
||||
public enum BT_MACHINE_STATE {BT_INIT_STATE, BT_CMD_STATE, BT_CLEANUP_STATE}
|
||||
public enum BT_MACHINE_STATE {BT_INIT_STATE, BT_CMD_STATE, BT_CLEANUP_STATE, BT_PAUSED_STATE}
|
||||
|
||||
private static final long LE_SCAN_TIMEOUT_MS = 10 * 1000;
|
||||
|
||||
@@ -66,6 +66,7 @@ public abstract class BluetoothCommunication {
|
||||
private int initStepNr;
|
||||
private int cleanupStepNr;
|
||||
private BT_MACHINE_STATE btMachineState;
|
||||
private BT_MACHINE_STATE btPausedMachineState;
|
||||
|
||||
private class GattObjectValue <GattObject> {
|
||||
public final GattObject gattObject;
|
||||
@@ -243,6 +244,29 @@ public abstract class BluetoothCommunication {
|
||||
handleRequests();
|
||||
}
|
||||
|
||||
protected void pauseBtStateMachine() {
|
||||
if (btMachineState != BT_MACHINE_STATE.BT_CLEANUP_STATE
|
||||
&& btMachineState != BT_MACHINE_STATE.BT_PAUSED_STATE) {
|
||||
btPausedMachineState = btMachineState;
|
||||
setBtMachineState(BT_MACHINE_STATE.BT_PAUSED_STATE);
|
||||
}
|
||||
}
|
||||
|
||||
protected void resumeBtStateMachine() {
|
||||
if (this.btMachineState == BT_MACHINE_STATE.BT_PAUSED_STATE) {
|
||||
setBtMachineState(btPausedMachineState);
|
||||
}
|
||||
}
|
||||
|
||||
protected void postHandleRequest() {
|
||||
handler.post(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
handleRequests();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a byte array to a Bluetooth device.
|
||||
*
|
||||
@@ -538,7 +562,6 @@ public abstract class BluetoothCommunication {
|
||||
if (doCleanup) {
|
||||
if (btMachineState != BT_MACHINE_STATE.BT_CLEANUP_STATE) {
|
||||
setBtMachineState(BT_MACHINE_STATE.BT_CLEANUP_STATE);
|
||||
nextMachineStateStep();
|
||||
}
|
||||
handler.post(new Runnable() {
|
||||
@Override
|
||||
@@ -580,6 +603,9 @@ public abstract class BluetoothCommunication {
|
||||
nextCleanUpCmd(cleanupStepNr);
|
||||
cleanupStepNr++;
|
||||
break;
|
||||
case BT_PAUSED_STATE:
|
||||
Timber.d("PAUSED STATE");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -302,12 +302,8 @@ public class UserSettingsActivity extends BaseAppCompatActivity {
|
||||
{
|
||||
boolean validate = true;
|
||||
|
||||
if (txtUserName.getText().toString().length() < 3) {
|
||||
if (txtUserName.getText().toString().length() == 0) {
|
||||
txtUserName.setError(getResources().getString(R.string.error_user_name_required));
|
||||
} else {
|
||||
txtUserName.setError(getResources().getString(R.string.error_user_name_too_short));
|
||||
}
|
||||
if (txtUserName.getText().toString().length() == 0) {
|
||||
txtUserName.setError(getResources().getString(R.string.error_user_name_required));
|
||||
validate = false;
|
||||
}
|
||||
|
||||
|
@@ -65,7 +65,6 @@
|
||||
<string name="error_exporting">Chyba při exportu</string>
|
||||
<string name="error_importing">Chyba při importu</string>
|
||||
<string name="error_user_name_required">Chyba: je třeba uživatelské jméno</string>
|
||||
<string name="error_user_name_too_short">Chyba: je třeba, aby uživatelské jméno bylo alespoň 3 znaky dlouhé</string>
|
||||
<string name="error_height_required">Chyba: je třeba zadat výšku člověka</string>
|
||||
<string name="error_initial_weight_required">Chyba: je třeba zadat počáteční hmotnost</string>
|
||||
<string name="error_goal_weight_required">Chyba: je třeba zadat cílovou hmotnost</string>
|
||||
|
@@ -130,7 +130,6 @@
|
||||
<string name="info_bluetooth_no_device_set">Ingen Bluetooth enhed er valgt</string>
|
||||
<string name="info_new_data_duplicated">En måling med samme dato og tidspunkt findes allerede</string>
|
||||
<string name="title_general">Generelt</string>
|
||||
<string name="error_user_name_too_short">Fejl: navnet skal være mindst tre tegn langt</string>
|
||||
<string name="label_theme">Tema</string>
|
||||
<string name="label_feedback_message_enjoying">Er du tilfreds med openScale?</string>
|
||||
<string name="label_feedback_message_rate_app">Vil du rate app"en på Google Play eller GitHub?</string>
|
||||
|
@@ -137,8 +137,7 @@
|
||||
<string name="label_feedback_message_positive">Ja klar</string>
|
||||
<string name="label_feedback_message_negative">Nein danke</string>
|
||||
<string name="label_feedback_message_issue">Würdest Du uns etwas Feedback geben?</string>
|
||||
<string name="error_user_name_too_short">Fehler: Benutzername muss aus mind. 3 Zeichen bestehen</string>
|
||||
<string name="customactivityoncrash_error_activity_error_occurred_explanation">Ein unerwarteter Fehler ist aufgetreten.
|
||||
<string name="customactivityoncrash_error_activity_error_occurred_explanation">Ein unerwarteter Fehler ist aufgetreten.
|
||||
\n
|
||||
\n Bitte erstellen Sie ein neues Issue mit detailliertem Fehlerbericht auf
|
||||
\nhttps://github.com/oliexdev/openScale/issues</string>
|
||||
|
@@ -73,7 +73,6 @@
|
||||
<string name="error_exporting">Σφάλμα κατά την εξαγωγή</string>
|
||||
<string name="error_importing">Σφάλμα κατά την εισαγωγή</string>
|
||||
<string name="error_user_name_required">Σφάλμα: Όνομα απαραίτητο</string>
|
||||
<string name="error_user_name_too_short">Σφάλμα: Το όνομα πρέπει να είναι 3 ή περισσότεροι χαρακτήρες</string>
|
||||
<string name="error_height_required">Σφάλμα: Ύψος απαραίτητο</string>
|
||||
<string name="error_initial_weight_required">Σφάλμα: Αρχικό βάρος απαραίτητο</string>
|
||||
<string name="error_goal_weight_required">Σφάλμα: Βάρος στόχος απαραίτητο</string>
|
||||
|
@@ -155,7 +155,6 @@
|
||||
|
||||
<string name="label_share">Compartir</string>
|
||||
|
||||
<string name="error_user_name_too_short">Error: El nombre debe contener 3 o más carácteres</string>
|
||||
<string name="info_bluetooth_no_device_set">"No hay dispositivo Bluetooth seleccionado "</string>
|
||||
<string name="info_new_data_duplicated">ya existe una medición con al misma fecha y hora</string>
|
||||
|
||||
|
@@ -147,7 +147,6 @@
|
||||
<string name="label_next">Suivant</string>
|
||||
<string name="title_about">À propos</string>
|
||||
<string name="label_share">Partager</string>
|
||||
<string name="error_user_name_too_short">Erreur : Le nom doit contenir au moins 3 caractères</string>
|
||||
<string name="error_initial_weight_required">Erreur : Poids initial requis</string>
|
||||
<string name="label_exportBackup">Exporter la sauvegarde</string>
|
||||
<string name="label_importBackup">Importer la sauvegarde</string>
|
||||
|
@@ -69,7 +69,6 @@
|
||||
<string name="error_exporting">Erro ao exportar</string>
|
||||
<string name="error_importing">Erro ao importar</string>
|
||||
<string name="error_user_name_required">Erro: O nome é obrigatorio</string>
|
||||
<string name="error_user_name_too_short">Erro: O nome debe ter 3 ou máis caracteres</string>
|
||||
<string name="error_height_required">Erro: A altura é obrigatoria</string>
|
||||
<string name="error_initial_weight_required">Erro: O peso inicial é obrigatorio</string>
|
||||
<string name="error_goal_weight_required">Erro: O peso obxectivo é obrigatorio</string>
|
||||
|
@@ -75,7 +75,6 @@
|
||||
<string name="error_exporting">Greška u izvozu</string>
|
||||
<string name="error_importing">Greška u uvozu</string>
|
||||
<string name="error_user_name_required">Greška: Unesite ime</string>
|
||||
<string name="error_user_name_too_short">Greška: Ime mora imati barem 3 znaka</string>
|
||||
<string name="error_height_required">Greška: Unesite težinu</string>
|
||||
<string name="error_initial_weight_required">Greška: Unesite početnu težinu</string>
|
||||
<string name="error_goal_weight_required">Greška: Unesite ciljanu težinu</string>
|
||||
|
@@ -73,7 +73,6 @@
|
||||
<string name="error_exporting">Errore durante l\'esportazione</string>
|
||||
<string name="error_importing">Errore di importazione</string>
|
||||
<string name="error_user_name_required">Errore: Nome obbligatorio</string>
|
||||
<string name="error_user_name_too_short">Errore: Il nome deve essere almeno 3 caratteri</string>
|
||||
<string name="error_height_required">Errore: Altezza necessaria</string>
|
||||
<string name="error_initial_weight_required">Errore: Peso iniziale richiesto</string>
|
||||
<string name="error_goal_weight_required">Errore: Obiettivo di peso richiesto</string>
|
||||
|
@@ -76,7 +76,6 @@
|
||||
<string name="error_exporting">שגיאה בייצוא</string>
|
||||
<string name="error_importing">שגיאה בייבוא</string>
|
||||
<string name="error_user_name_required">שגיאה: נדרש שם</string>
|
||||
<string name="error_user_name_too_short">שגיאה: שם חייב להיות באורך 3 תווים ומעלה</string>
|
||||
<string name="error_height_required">שגיאה: נדרש גובה</string>
|
||||
<string name="error_initial_weight_required">שגיאה: נדרש גובה ראשוני</string>
|
||||
<string name="error_goal_weight_required">שגיאה: נדרש משקל יעד</string>
|
||||
|
@@ -109,7 +109,6 @@
|
||||
<string name="label_bmr">基礎代謝率 (BMR)</string>
|
||||
<string name="label_lbm">除脂肪体重</string>
|
||||
<string name="label_bone">骨密度</string>
|
||||
<string name="error_user_name_too_short">エラー:名前は3文字以上でなければいけません</string>
|
||||
<string name="info_bluetooth_no_device_set">Bluetooth端末が選択されていません</string>
|
||||
<string name="info_new_data_duplicated">同じ日時のものがすでに存在している測定</string>
|
||||
|
||||
|
@@ -74,7 +74,6 @@
|
||||
<string name="error_exporting">Feil ved eksport</string>
|
||||
<string name="error_importing">Feil ved import</string>
|
||||
<string name="error_user_name_required">Feil: Brukernavn kreves</string>
|
||||
<string name="error_user_name_too_short">Feil: Brukernavn må være minst tre tegn</string>
|
||||
<string name="error_height_required">Feil: Høyde kreves</string>
|
||||
<string name="error_initial_weight_required">Feil: Startvekt kreves</string>
|
||||
<string name="error_goal_weight_required">Feil: Målvekt kreves</string>
|
||||
|
@@ -74,7 +74,6 @@
|
||||
<string name="error_exporting">Fout met exporteren</string>
|
||||
<string name="error_importing">Fout met importeren</string>
|
||||
<string name="error_user_name_required">Fout: Naam vereist</string>
|
||||
<string name="error_user_name_too_short">Fout: Naam moet 3 karakters of meer zijn</string>
|
||||
<string name="error_height_required">Fout: Lichaamslengte vereist</string>
|
||||
<string name="error_initial_weight_required">Fout: Startgewicht vereist</string>
|
||||
<string name="error_goal_weight_required">Fout: Streefgewicht vereist</string>
|
||||
|
@@ -74,7 +74,6 @@
|
||||
<string name="error_exporting">Błąd eksportu</string>
|
||||
<string name="error_importing">Błąd importu</string>
|
||||
<string name="error_user_name_required">Błąd: wymagana nazwa użytkownika</string>
|
||||
<string name="error_user_name_too_short">Błąd: nazwa użytkownika musi mieć przynajmniej 3 znaki</string>
|
||||
<string name="error_height_required">Błąd: wymagany wzrost</string>
|
||||
<string name="error_initial_weight_required">Błąd: wymagana waga początkowa</string>
|
||||
<string name="error_goal_weight_required">Błąd: wymagana waga docelowa</string>
|
||||
|
@@ -143,7 +143,6 @@
|
||||
<string name="label_theme">Tema</string>
|
||||
|
||||
<string name="label_bt_device_no_support">dispositivo não suportado</string>
|
||||
<string name="error_user_name_too_short">Erro: nome de usuário tem que ter 3 ou mais caracteres</string>
|
||||
<string name="label_help">Ajuda</string>
|
||||
|
||||
<string name="label_feedback_message_enjoying">Gostou do openScale?</string>
|
||||
|
@@ -73,7 +73,6 @@
|
||||
<string name="error_exporting">Eroare la export</string>
|
||||
<string name="error_importing">Eroare la import</string>
|
||||
<string name="error_user_name_required">Eroare: Nume necesar</string>
|
||||
<string name="error_user_name_too_short">Eroare: Numele trebuie să aibă cel puțin trei caractere</string>
|
||||
<string name="error_height_required">Eroare: Înălțime necesară</string>
|
||||
<string name="error_initial_weight_required">Eroare: Greutatea inițială trebuie indicată</string>
|
||||
<string name="error_goal_weight_required">Eroare: Greutatea dorită trebuie indicată</string>
|
||||
|
@@ -70,7 +70,6 @@
|
||||
<string name="error_exporting">Ошибка при экспортировании</string>
|
||||
<string name="error_importing">Ошибка при импортировании</string>
|
||||
<string name="error_user_name_required">Ошибка: необходимо Имя</string>
|
||||
<string name="error_user_name_too_short">Ошибка: Имя должно быть длиннее 3 символов</string>
|
||||
<string name="error_height_required">Ошибка: требуется указать Рост</string>
|
||||
<string name="error_initial_weight_required">Ошибка: необходимо указать начальный вес</string>
|
||||
<string name="error_goal_weight_required">Ошибка: необходимо указать цель по весу</string>
|
||||
|
@@ -126,7 +126,6 @@
|
||||
<string name="label_share">Zdieľať</string>
|
||||
<string name="label_share_subject">openScale CSV export údajov (%s)</string>
|
||||
|
||||
<string name="error_user_name_too_short">Chyba: používateľské meno musí obsahovať minimálne 3 znaky</string>
|
||||
<string name="info_bluetooth_no_device_set">Nevybrali ste žiadne bluetooth zariadenie</string>
|
||||
<string name="info_new_data_duplicated">meranie s rovnakým dátumom a časom už existuje</string>
|
||||
|
||||
|
@@ -75,7 +75,6 @@
|
||||
<string name="error_exporting">Napaka pri izvozu</string>
|
||||
<string name="error_importing">Napaka pri uvozu</string>
|
||||
<string name="error_user_name_required">Napaka: zahtevno ime</string>
|
||||
<string name="error_user_name_too_short">Napaka: Ime mora imeti vsaj 3 znake</string>
|
||||
<string name="error_height_required">Napaka: Višina zahtevana</string>
|
||||
<string name="error_initial_weight_required">Napaka: Začetna teža zahtevana</string>
|
||||
<string name="error_goal_weight_required">Napaka: Ciljna teža zahtevana</string>
|
||||
|
@@ -130,7 +130,6 @@
|
||||
<string name="info_bluetooth_no_device_set">Ingen Bluetooth-enhet vald</string>
|
||||
<string name="info_new_data_duplicated">mätning med samma datum och tid existerar redan</string>
|
||||
<string name="title_general">Allmänt</string>
|
||||
<string name="error_user_name_too_short">Fel: namn måste vara minst 3 tecken</string>
|
||||
<string name="label_theme">Tema</string>
|
||||
<string name="label_feedback_message_enjoying">Uppskattar du openScale?</string>
|
||||
<string name="label_feedback_message_rate_app">Vad sägs om ett betyg på Google Play eller GitHub?</string>
|
||||
|
@@ -154,7 +154,6 @@
|
||||
|
||||
<string name="label_add_measurement">Ölçüm ekle</string>
|
||||
<string name="label_share">Paylaş</string>
|
||||
<string name="error_user_name_too_short">Hata: Ad 3 karakter veya daha fazla olmalı</string>
|
||||
<string name="info_bluetooth_no_device_set">Seçili Bluetooth aygıtı yok</string>
|
||||
<string name="info_new_data_duplicated">Aynı tarih ve saatte ölçüm zaten var</string>
|
||||
|
||||
|
@@ -72,7 +72,6 @@
|
||||
<string name="error_exporting">Lỗi khi xuất</string>
|
||||
<string name="error_importing">Lỗi khi nhập</string>
|
||||
<string name="error_user_name_required">Lỗi: Tên là bắt buộc</string>
|
||||
<string name="error_user_name_too_short">Lỗi: Tên phải ít nhất 3 kí tự</string>
|
||||
<string name="error_height_required">Lỗi: Chiều cao là bắt buộc</string>
|
||||
<string name="error_initial_weight_required">Lỗi: Trọng lượng ban đầu là bắt buộc</string>
|
||||
<string name="error_goal_weight_required">Lỗi: Trọng lượng mục tiêu là bắt buộc</string>
|
||||
|
@@ -72,7 +72,6 @@
|
||||
<string name="error_exporting">滙出出錯</string>
|
||||
<string name="error_importing">滙入出錯</string>
|
||||
<string name="error_user_name_required">錯誤: 必須輸入名字</string>
|
||||
<string name="error_user_name_too_short">錯誤: 名字必須三個字元或以上</string>
|
||||
<string name="error_height_required">錯誤: 必須輸入身高</string>
|
||||
<string name="error_initial_weight_required">錯誤: 必須輸入初始體重</string>
|
||||
<string name="error_goal_weight_required">錯誤: 必須輸入目標體重</string>
|
||||
|
@@ -75,7 +75,6 @@
|
||||
<string name="error_exporting">Error exporting</string>
|
||||
<string name="error_importing">Error importing</string>
|
||||
<string name="error_user_name_required">Error: Name required</string>
|
||||
<string name="error_user_name_too_short">Error: Name must be 3 characters or more</string>
|
||||
<string name="error_height_required">Error: Height required</string>
|
||||
<string name="error_initial_weight_required">Error: Initial weight required</string>
|
||||
<string name="error_goal_weight_required">Error: Goal weight required</string>
|
||||
|
@@ -24,9 +24,12 @@ Initialization
|
||||
2. Write: `<alt sb 6> 01`
|
||||
3. Notification: `<alt sb 6> 00 20`
|
||||
4. Write: `<alt sb 9> <timestamp>`
|
||||
5. Write: `<sb> 4f <8 bytes 00>`
|
||||
5. Write: `<sb> 4f <uid>`
|
||||
6. Notification: `<sb> f0 4f <??> <battery> <wthr> <fthr> <unit> <ue> <urwe> <ume> <version>`
|
||||
|
||||
`<uid>` can be given as all 0 (or other invalid user id) to query
|
||||
scale status only.
|
||||
|
||||
* `<battery>`: battery level
|
||||
* `<wthr>`: weight threshold (unit g / 100)
|
||||
* `<fthr>`: fat threshold
|
||||
@@ -40,6 +43,11 @@ Initialization
|
||||
1. Write: `<sb> 4e <wthr> <fthr>`
|
||||
2. Notification: `<sb> f0 4e 00`
|
||||
|
||||
Thresholds in original app:
|
||||
* 0x28 0xdc
|
||||
* 0x14 0xdc
|
||||
* 0x0a 0x14
|
||||
|
||||
|
||||
### Set unit
|
||||
|
||||
@@ -122,7 +130,7 @@ Measurements
|
||||
4. Write: `<sb> f1 42 <count> <current>`
|
||||
5. Goto 3 if `<count> != <current>`
|
||||
|
||||
* All `<data>` from step 3 is joined and parsed as a measurement.
|
||||
* All `<data>` from step 3 is joined and parsed as `<count> / 2` measurement(s).
|
||||
|
||||
|
||||
### Delete saved measurements
|
||||
|
Reference in New Issue
Block a user