1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-09-02 12:54:10 +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:
Erik Johansson
2018-12-01 20:02:29 +01:00
28 changed files with 512 additions and 415 deletions

View File

@@ -29,13 +29,12 @@ import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser; import com.health.openscale.core.datatypes.ScaleUser;
import com.health.openscale.core.utils.Converters; import com.health.openscale.core.utils.Converters;
import java.io.ByteArrayOutputStream; import java.text.Normalizer;
import java.io.IOException; import java.util.ArrayList;
import java.text.ParseException;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Calendar;
import java.util.Date; import java.util.Date;
import java.util.TreeSet; import java.util.Locale;
import java.util.UUID; import java.util.UUID;
import timber.log.Timber; import timber.log.Timber;
@@ -47,15 +46,98 @@ public class BluetoothBeurerSanitas extends BluetoothCommunication {
private static final UUID CUSTOM_CHARACTERISTIC_WEIGHT = BluetoothGattUuid.fromShortCode(0xffe1); private static final UUID CUSTOM_CHARACTERISTIC_WEIGHT = BluetoothGattUuid.fromShortCode(0xffe1);
private final DeviceType deviceType; private final DeviceType deviceType;
private int startByte; private byte startByte;
private int currentScaleUserId;
private int countRegisteredScaleUsers;
private TreeSet<Integer> seenUsers;
private int maxRegisteredScaleUser;
private ByteArrayOutputStream receivedScaleData;
private int getAlternativeStartByte(int id) { private class RemoteUser {
return (startByte & 0xF0) | (id & 0x0F); 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) { public BluetoothBeurerSanitas(Context context, DeviceType deviceType) {
@@ -64,11 +146,11 @@ public class BluetoothBeurerSanitas extends BluetoothCommunication {
this.deviceType = deviceType; this.deviceType = deviceType;
switch (deviceType) { switch (deviceType) {
case BEURER_BF700_800_RT_LIBRA: case BEURER_BF700_800_RT_LIBRA:
startByte = 0xf7; startByte = (byte) (0xf0 | ID_START_NIBBLE_CMD);
break; break;
case BEURER_BF710: case BEURER_BF710:
case SANITAS_SBF70_70: case SANITAS_SBF70_70:
startByte = 0xe7; startByte = (byte) (0xe0 | ID_START_NIBBLE_CMD);
break; break;
} }
} }
@@ -92,92 +174,77 @@ public class BluetoothBeurerSanitas extends BluetoothCommunication {
switch (stateNr) { switch (stateNr) {
case 0: case 0:
// Initialize data
currentScaleUserId = -1;
countRegisteredScaleUsers = -1;
maxRegisteredScaleUser = -1;
seenUsers = new TreeSet<>();
// Setup notification // Setup notification
setNotificationOn(CUSTOM_SERVICE_1, CUSTOM_CHARACTERISTIC_WEIGHT, setNotificationOn(CUSTOM_SERVICE_1, CUSTOM_CHARACTERISTIC_WEIGHT,
BluetoothGattUuid.DESCRIPTOR_CLIENT_CHARACTERISTIC_CONFIGURATION); BluetoothGattUuid.DESCRIPTOR_CLIENT_CHARACTERISTIC_CONFIGURATION);
break; break;
case 1: case 1:
// Say "Hello" to the scale // Say "Hello" to the scale and wait for ack
writeBytes(new byte[]{(byte) getAlternativeStartByte(6), (byte) 0x01}); sendAlternativeStartCode(ID_START_NIBBLE_INIT, (byte) 0x01);
pauseBtStateMachine();
break; break;
case 2: 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; break;
case 3: case 3:
// Update timestamp of the scale // Request scale status and wait for ack
updateDateTime(); sendCommand(CMD_SCALE_STATUS, encodeUserId(null));
pauseBtStateMachine();
break; break;
case 4: case 4:
// Set measurement unit // Request list of all users and wait until all have been received
setUnitCommand(); sendCommand(CMD_USER_LIST);
pauseBtStateMachine();
break; break;
case 5: case 5:
// Wait for "unit" ack from scale // If currentRemoteUser is null, indexOf returns -1 and index will be 0
int index = remoteUsers.indexOf(currentRemoteUser) + 1;
currentRemoteUser = null;
// Find the next remote user that exists locally
for (; index < remoteUsers.size(); ++index) {
if (remoteUsers.get(index).localUserId != -1) {
currentRemoteUser = remoteUsers.get(index);
break;
}
}
// 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; break;
case 6: case 6:
// Request general user information // Create a remote user for selected openScale user if needed
writeBytes(new byte[]{(byte) startByte, (byte) 0x33}); 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; break;
case 7: case 7:
// Wait for ack of all users sendCommand(CMD_USER_DETAILS, encodeUserId(currentRemoteUser));
if (seenUsers.size() < countRegisteredScaleUsers || (countRegisteredScaleUsers == -1)) { pauseBtStateMachine();
// Request this state again
setNextCmd(stateNr);
break;
}
// 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);
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());
break;
case 8:
break; break;
default: default:
// Finish init if everything is done // Finish init if everything is done
@@ -189,24 +256,17 @@ public class BluetoothBeurerSanitas extends BluetoothCommunication {
@Override @Override
protected boolean nextBluetoothCmd(int stateNr) { protected boolean nextBluetoothCmd(int stateNr) {
switch (stateNr) { switch (stateNr) {
case 0: case 0:
// If no specific user selected if (!currentRemoteUser.isNew) {
if (currentScaleUserId == 0) sendCommand(CMD_DO_MEASUREMENT, encodeUserId(currentRemoteUser));
break; pauseBtStateMachine();
}
Timber.d("Request Saved User Measurements"); else {
writeBytes(new byte[]{ postHandleRequest();
(byte) startByte, (byte) 0x41, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, (byte) currentScaleUserId }
});
break; break;
case 1: case 1:
// Wait for user measurements to be received
setNextCmd(stateNr);
break;
case 2:
setBtMachineState(BT_MACHINE_STATE.BT_CLEANUP_STATE); setBtMachineState(BT_MACHINE_STATE.BT_CLEANUP_STATE);
break; break;
default: default:
@@ -221,7 +281,7 @@ public class BluetoothBeurerSanitas extends BluetoothCommunication {
switch (stateNr) { switch (stateNr) {
case 0: case 0:
// Force disconnect // Force disconnect
writeBytes(new byte[]{(byte) 0xea, (byte) 0x02}); sendAlternativeStartCode(ID_START_NIBBLE_DISCONNECT, (byte) 0x02);
break; break;
default: default:
return false; return false;
@@ -232,264 +292,271 @@ public class BluetoothBeurerSanitas extends BluetoothCommunication {
@Override @Override
public void onBluetoothDataChange(BluetoothGatt bluetoothGatt, BluetoothGattCharacteristic gattCharacteristic) { public void onBluetoothDataChange(BluetoothGatt bluetoothGatt, BluetoothGattCharacteristic gattCharacteristic) {
byte[] data = gattCharacteristic.getValue(); byte[] data = gattCharacteristic.getValue();
if (data.length == 0) { if (data == null || data.length == 0) {
return; return;
} }
if ((data[0] & 0xFF) == getAlternativeStartByte(6) && (data[1] & 0xFF) == 0x00) { if (data[0] == getAlternativeStartByte(ID_START_NIBBLE_INIT)) {
Timber.d("ACK Scale is ready"); Timber.d("Got init ack from scale; scale is ready");
nextMachineStateStep(); resumeBtStateMachine();
return; return;
} }
if ((data[0] & 0xFF) == startByte && (data[1] & 0xFF) == 0xf0 && data[2] == 0x4d) { if (data[0] != startByte) {
Timber.d("ACK Unit set"); Timber.e("Got unknown start byte 0x%02x", data[0]);
nextMachineStateStep();
return; 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
}
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;
}
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 { try {
receivedScaleData.write(Arrays.copyOfRange(data, 4, data.length)); switch (data[1]) {
} catch (IOException e) { case CMD_USER_INFO:
Timber.e(e, "Failed to copy user specific data"); 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;
} }
}
// Send acknowledgement catch (IndexOutOfBoundsException|NullPointerException e) {
writeBytes(new byte[]{ Timber.e(e);
(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) { private void processUserInfo(byte[] data) {
// finish and delete final int count = data[2] & 0xFF;
deleteScaleData(); 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; return;
} }
if ((data[0] & 0xFF) == startByte && (data[1] & 0xFF) == 0x58) { 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); float weight = getKiloGram(data, 3);
if ((data[2] & 0xFF) != 0x00) {
// temporary value; if (!stableMeasurement) {
Timber.d("Active measurement, weight: %.2f", weight); Timber.d("Active measurement, weight: %.2f", weight);
sendMessage(R.string.info_measuring, weight); sendMessage(R.string.info_measuring, weight);
return; return;
} }
Timber.i("Active measurement, stable weight: %.2f", weight); 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; 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);
} }
if ((data[0] & 0xFF) == startByte && (data[1] & 0xFF) == 0x59) { sendAck(data);
// Get stable measurement results
Timber.d("Get measurement data %d", (int) data[3]);
int max_items = (data[2] & 0xFF); if (current == count) {
int current_item = (data[3] & 0xFF); sendCommand(CMD_DELETE_SAVED_MEASUREMENTS, encodeUserId(currentRemoteUser));
}
}
// Received first part private void processScaleAck(byte[] data) {
if (current_item == 1) { switch (data[2]) {
receivedScaleData = new ByteArrayOutputStream(); 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 { } else {
try { resumeBtStateMachine();
receivedScaleData.write(Arrays.copyOfRange(data, 4, data.length));
} catch (IOException e) {
Timber.e(e, "Failed to copy stable measurement array");
} }
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;
} }
// Send ack that we got the data Timber.d("Cannot create additional scale user (error 0x%02x)", data[3]);
writeBytes(new byte[]{ sendMessage(R.string.error_max_scale_users, 0);
(byte) startByte, (byte) 0xf1, setBtMachineState(BT_MACHINE_STATE.BT_CLEANUP_STATE);
(byte) (data[1] & 0xFF), (byte) (data[2] & 0xFF), break;
(byte) (data[3] & 0xFF),
});
if (current_item == max_items) { case CMD_DO_MEASUREMENT:
// received all parts if (data[3] == 0) {
try { Timber.d("Measure command successfully received");
ScaleMeasurement parsedData = parseScaleData(receivedScaleData.toByteArray());
addScaleData(parsedData);
// Delete data
deleteScaleData();
} catch (ParseException e) {
Timber.d(e, "Parse Exception %s", byteInHex(receivedScaleData.toByteArray()));
}
} }
break;
return; 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;
if ((data[0] & 0xFF) == startByte && (data[1] & 0xFF) == 0xf0 && (data[2] & 0xFF) == 0x43) { int height = data[10] & 0xFF;
Timber.d("Acknowledge: Data deleted."); boolean male = (data[11] & 0xF0) != 0;
return; int activity = data[11] & 0x0F;
}
Timber.d("DataChanged - not handled: %s", byteInHex(data)); 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;
private void deleteScaleData() { default:
writeBytes(new byte[]{ Timber.d("Unhandled scale ack for command 0x%02x", data[2]);
(byte) startByte, (byte) 0x43, (byte) 0x0, (byte) 0x0, (byte) 0x0, break;
(byte) 0x0, (byte) 0x0, (byte) 0x0, (byte) 0x0, }
(byte) currentScaleUserId
});
} }
private float getKiloGram(byte[] data, int offset) { private float getKiloGram(byte[] data, int offset) {
@@ -502,11 +569,7 @@ public class BluetoothBeurerSanitas extends BluetoothCommunication {
return Converters.fromUnsignedInt16Be(data, offset) / 10.0f; return Converters.fromUnsignedInt16Be(data, offset) / 10.0f;
} }
private ScaleMeasurement parseScaleData(byte[] data) throws ParseException { private void addMeasurement(byte[] data, int userId) {
if (data.length != 11 + 11) {
throw new ParseException("Parse scala data: unexpected length", 0);
}
long timestamp = Converters.fromUnsignedInt32Be(data, 0) * 1000; long timestamp = Converters.fromUnsignedInt32Be(data, 0) * 1000;
float weight = getKiloGram(data, 4); float weight = getKiloGram(data, 4);
int impedance = Converters.fromUnsignedInt16Be(data, 6); int impedance = Converters.fromUnsignedInt16Be(data, 6);
@@ -519,6 +582,7 @@ public class BluetoothBeurerSanitas extends BluetoothCommunication {
float bmi = Converters.fromUnsignedInt16Be(data, 20) / 10.0f; float bmi = Converters.fromUnsignedInt16Be(data, 20) / 10.0f;
ScaleMeasurement receivedMeasurement = new ScaleMeasurement(); ScaleMeasurement receivedMeasurement = new ScaleMeasurement();
receivedMeasurement.setUserId(userId);
receivedMeasurement.setDateTime(new Date(timestamp)); receivedMeasurement.setDateTime(new Date(timestamp));
receivedMeasurement.setWeight(weight); receivedMeasurement.setWeight(weight);
receivedMeasurement.setFat(fat); receivedMeasurement.setFat(fat);
@@ -526,42 +590,69 @@ public class BluetoothBeurerSanitas extends BluetoothCommunication {
receivedMeasurement.setMuscle(muscle); receivedMeasurement.setMuscle(muscle);
receivedMeasurement.setBone(bone); receivedMeasurement.setBone(bone);
Timber.i("Measurement: %s, Impedance: %d, BMR: %d, AMR: %d, BMI: %.2f", addScaleData(receivedMeasurement);
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);
} }
private void writeBytes(byte[] data) { private void writeBytes(byte[] data) {
writeBytes(CUSTOM_SERVICE_1, CUSTOM_CHARACTERISTIC_WEIGHT, 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));
}
} }

View File

@@ -48,7 +48,7 @@ public abstract class BluetoothCommunication {
BT_CONNECTION_LOST, BT_NO_DEVICE_FOUND, BT_UNEXPECTED_ERROR, BT_SCALE_MESSAGE 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; private static final long LE_SCAN_TIMEOUT_MS = 10 * 1000;
@@ -66,6 +66,7 @@ public abstract class BluetoothCommunication {
private int initStepNr; private int initStepNr;
private int cleanupStepNr; private int cleanupStepNr;
private BT_MACHINE_STATE btMachineState; private BT_MACHINE_STATE btMachineState;
private BT_MACHINE_STATE btPausedMachineState;
private class GattObjectValue <GattObject> { private class GattObjectValue <GattObject> {
public final GattObject gattObject; public final GattObject gattObject;
@@ -243,6 +244,29 @@ public abstract class BluetoothCommunication {
handleRequests(); 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. * Write a byte array to a Bluetooth device.
* *
@@ -538,7 +562,6 @@ public abstract class BluetoothCommunication {
if (doCleanup) { if (doCleanup) {
if (btMachineState != BT_MACHINE_STATE.BT_CLEANUP_STATE) { if (btMachineState != BT_MACHINE_STATE.BT_CLEANUP_STATE) {
setBtMachineState(BT_MACHINE_STATE.BT_CLEANUP_STATE); setBtMachineState(BT_MACHINE_STATE.BT_CLEANUP_STATE);
nextMachineStateStep();
} }
handler.post(new Runnable() { handler.post(new Runnable() {
@Override @Override
@@ -580,6 +603,9 @@ public abstract class BluetoothCommunication {
nextCleanUpCmd(cleanupStepNr); nextCleanUpCmd(cleanupStepNr);
cleanupStepNr++; cleanupStepNr++;
break; break;
case BT_PAUSED_STATE:
Timber.d("PAUSED STATE");
break;
} }
} }

View File

@@ -302,12 +302,8 @@ public class UserSettingsActivity extends BaseAppCompatActivity {
{ {
boolean validate = true; boolean validate = true;
if (txtUserName.getText().toString().length() < 3) {
if (txtUserName.getText().toString().length() == 0) { if (txtUserName.getText().toString().length() == 0) {
txtUserName.setError(getResources().getString(R.string.error_user_name_required)); txtUserName.setError(getResources().getString(R.string.error_user_name_required));
} else {
txtUserName.setError(getResources().getString(R.string.error_user_name_too_short));
}
validate = false; validate = false;
} }

View File

@@ -65,7 +65,6 @@
<string name="error_exporting">Chyba při exportu</string> <string name="error_exporting">Chyba při exportu</string>
<string name="error_importing">Chyba při importu</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_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_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_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> <string name="error_goal_weight_required">Chyba: je třeba zadat cílovou hmotnost</string>

View File

@@ -130,7 +130,6 @@
<string name="info_bluetooth_no_device_set">Ingen Bluetooth enhed er valgt</string> <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="info_new_data_duplicated">En måling med samme dato og tidspunkt findes allerede</string>
<string name="title_general">Generelt</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_theme">Tema</string>
<string name="label_feedback_message_enjoying">Er du tilfreds med openScale?</string> <string name="label_feedback_message_enjoying">Er du tilfreds med openScale?</string>
<string name="label_feedback_message_rate_app">Vil du rate app&quot;en på Google Play eller GitHub?</string> <string name="label_feedback_message_rate_app">Vil du rate app&quot;en på Google Play eller GitHub?</string>

View File

@@ -137,7 +137,6 @@
<string name="label_feedback_message_positive">Ja klar</string> <string name="label_feedback_message_positive">Ja klar</string>
<string name="label_feedback_message_negative">Nein danke</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="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
\n Bitte erstellen Sie ein neues Issue mit detailliertem Fehlerbericht auf \n Bitte erstellen Sie ein neues Issue mit detailliertem Fehlerbericht auf

View File

@@ -73,7 +73,6 @@
<string name="error_exporting">Σφάλμα κατά την εξαγωγή</string> <string name="error_exporting">Σφάλμα κατά την εξαγωγή</string>
<string name="error_importing">Σφάλμα κατά την εισαγωγή</string> <string name="error_importing">Σφάλμα κατά την εισαγωγή</string>
<string name="error_user_name_required">Σφάλμα: Όνομα απαραίτητο</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_height_required">Σφάλμα: Ύψος απαραίτητο</string>
<string name="error_initial_weight_required">Σφάλμα: Αρχικό βάρος απαραίτητο</string> <string name="error_initial_weight_required">Σφάλμα: Αρχικό βάρος απαραίτητο</string>
<string name="error_goal_weight_required">Σφάλμα: Βάρος στόχος απαραίτητο</string> <string name="error_goal_weight_required">Σφάλμα: Βάρος στόχος απαραίτητο</string>

View File

@@ -155,7 +155,6 @@
<string name="label_share">Compartir</string> <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_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> <string name="info_new_data_duplicated">ya existe una medición con al misma fecha y hora</string>

View File

@@ -147,7 +147,6 @@
<string name="label_next">Suivant</string> <string name="label_next">Suivant</string>
<string name="title_about">À propos</string> <string name="title_about">À propos</string>
<string name="label_share">Partager</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="error_initial_weight_required">Erreur : Poids initial requis</string>
<string name="label_exportBackup">Exporter la sauvegarde</string> <string name="label_exportBackup">Exporter la sauvegarde</string>
<string name="label_importBackup">Importer la sauvegarde</string> <string name="label_importBackup">Importer la sauvegarde</string>

View File

@@ -69,7 +69,6 @@
<string name="error_exporting">Erro ao exportar</string> <string name="error_exporting">Erro ao exportar</string>
<string name="error_importing">Erro ao importar</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_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_height_required">Erro: A altura é obrigatoria</string>
<string name="error_initial_weight_required">Erro: O peso inicial é obrigatorio</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> <string name="error_goal_weight_required">Erro: O peso obxectivo é obrigatorio</string>

View File

@@ -75,7 +75,6 @@
<string name="error_exporting">Greška u izvozu</string> <string name="error_exporting">Greška u izvozu</string>
<string name="error_importing">Greška u uvozu</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_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_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_initial_weight_required">Greška: Unesite početnu težinu</string>
<string name="error_goal_weight_required">Greška: Unesite ciljanu težinu</string> <string name="error_goal_weight_required">Greška: Unesite ciljanu težinu</string>

View File

@@ -73,7 +73,6 @@
<string name="error_exporting">Errore durante l\'esportazione</string> <string name="error_exporting">Errore durante l\'esportazione</string>
<string name="error_importing">Errore di importazione</string> <string name="error_importing">Errore di importazione</string>
<string name="error_user_name_required">Errore: Nome obbligatorio</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_height_required">Errore: Altezza necessaria</string>
<string name="error_initial_weight_required">Errore: Peso iniziale richiesto</string> <string name="error_initial_weight_required">Errore: Peso iniziale richiesto</string>
<string name="error_goal_weight_required">Errore: Obiettivo di peso richiesto</string> <string name="error_goal_weight_required">Errore: Obiettivo di peso richiesto</string>

View File

@@ -76,7 +76,6 @@
<string name="error_exporting">שגיאה בייצוא</string> <string name="error_exporting">שגיאה בייצוא</string>
<string name="error_importing">שגיאה בייבוא</string> <string name="error_importing">שגיאה בייבוא</string>
<string name="error_user_name_required">שגיאה: נדרש שם</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_height_required">שגיאה: נדרש גובה</string>
<string name="error_initial_weight_required">שגיאה: נדרש גובה ראשוני</string> <string name="error_initial_weight_required">שגיאה: נדרש גובה ראשוני</string>
<string name="error_goal_weight_required">שגיאה: נדרש משקל יעד</string> <string name="error_goal_weight_required">שגיאה: נדרש משקל יעד</string>

View File

@@ -109,7 +109,6 @@
<string name="label_bmr">基礎代謝率 (BMR)</string> <string name="label_bmr">基礎代謝率 (BMR)</string>
<string name="label_lbm">除脂肪体重</string> <string name="label_lbm">除脂肪体重</string>
<string name="label_bone">骨密度</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_bluetooth_no_device_set">Bluetooth端末が選択されていません</string>
<string name="info_new_data_duplicated">同じ日時のものがすでに存在している測定</string> <string name="info_new_data_duplicated">同じ日時のものがすでに存在している測定</string>

View File

@@ -74,7 +74,6 @@
<string name="error_exporting">Feil ved eksport</string> <string name="error_exporting">Feil ved eksport</string>
<string name="error_importing">Feil ved import</string> <string name="error_importing">Feil ved import</string>
<string name="error_user_name_required">Feil: Brukernavn kreves</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_height_required">Feil: Høyde kreves</string>
<string name="error_initial_weight_required">Feil: Startvekt kreves</string> <string name="error_initial_weight_required">Feil: Startvekt kreves</string>
<string name="error_goal_weight_required">Feil: Målvekt kreves</string> <string name="error_goal_weight_required">Feil: Målvekt kreves</string>

View File

@@ -74,7 +74,6 @@
<string name="error_exporting">Fout met exporteren</string> <string name="error_exporting">Fout met exporteren</string>
<string name="error_importing">Fout met importeren</string> <string name="error_importing">Fout met importeren</string>
<string name="error_user_name_required">Fout: Naam vereist</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_height_required">Fout: Lichaamslengte vereist</string>
<string name="error_initial_weight_required">Fout: Startgewicht vereist</string> <string name="error_initial_weight_required">Fout: Startgewicht vereist</string>
<string name="error_goal_weight_required">Fout: Streefgewicht vereist</string> <string name="error_goal_weight_required">Fout: Streefgewicht vereist</string>

View File

@@ -74,7 +74,6 @@
<string name="error_exporting">Błąd eksportu</string> <string name="error_exporting">Błąd eksportu</string>
<string name="error_importing">Błąd importu</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_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_height_required">Błąd: wymagany wzrost</string>
<string name="error_initial_weight_required">Błąd: wymagana waga początkowa</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> <string name="error_goal_weight_required">Błąd: wymagana waga docelowa</string>

View File

@@ -143,7 +143,6 @@
<string name="label_theme">Tema</string> <string name="label_theme">Tema</string>
<string name="label_bt_device_no_support">dispositivo não suportado</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_help">Ajuda</string>
<string name="label_feedback_message_enjoying">Gostou do openScale?</string> <string name="label_feedback_message_enjoying">Gostou do openScale?</string>

View File

@@ -73,7 +73,6 @@
<string name="error_exporting">Eroare la export</string> <string name="error_exporting">Eroare la export</string>
<string name="error_importing">Eroare la import</string> <string name="error_importing">Eroare la import</string>
<string name="error_user_name_required">Eroare: Nume necesar</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_height_required">Eroare: Înălțime necesară</string>
<string name="error_initial_weight_required">Eroare: Greutatea inițială trebuie indicată</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> <string name="error_goal_weight_required">Eroare: Greutatea dorită trebuie indicată</string>

View File

@@ -70,7 +70,6 @@
<string name="error_exporting">Ошибка при экспортировании</string> <string name="error_exporting">Ошибка при экспортировании</string>
<string name="error_importing">Ошибка при импортировании</string> <string name="error_importing">Ошибка при импортировании</string>
<string name="error_user_name_required">Ошибка: необходимо Имя</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_height_required">Ошибка: требуется указать Рост</string>
<string name="error_initial_weight_required">Ошибка: необходимо указать начальный вес</string> <string name="error_initial_weight_required">Ошибка: необходимо указать начальный вес</string>
<string name="error_goal_weight_required">Ошибка: необходимо указать цель по весу</string> <string name="error_goal_weight_required">Ошибка: необходимо указать цель по весу</string>

View File

@@ -126,7 +126,6 @@
<string name="label_share">Zdieľať</string> <string name="label_share">Zdieľať</string>
<string name="label_share_subject">openScale CSV export údajov (%s)</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_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> <string name="info_new_data_duplicated">meranie s rovnakým dátumom a časom už existuje</string>

View File

@@ -75,7 +75,6 @@
<string name="error_exporting">Napaka pri izvozu</string> <string name="error_exporting">Napaka pri izvozu</string>
<string name="error_importing">Napaka pri uvozu</string> <string name="error_importing">Napaka pri uvozu</string>
<string name="error_user_name_required">Napaka: zahtevno ime</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_height_required">Napaka: Višina zahtevana</string>
<string name="error_initial_weight_required">Napaka: Začetna teža 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> <string name="error_goal_weight_required">Napaka: Ciljna teža zahtevana</string>

View File

@@ -130,7 +130,6 @@
<string name="info_bluetooth_no_device_set">Ingen Bluetooth-enhet vald</string> <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="info_new_data_duplicated">mätning med samma datum och tid existerar redan</string>
<string name="title_general">Allmänt</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_theme">Tema</string>
<string name="label_feedback_message_enjoying">Uppskattar du openScale?</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> <string name="label_feedback_message_rate_app">Vad sägs om ett betyg på Google Play eller GitHub?</string>

View File

@@ -154,7 +154,6 @@
<string name="label_add_measurement">Ölçüm ekle</string> <string name="label_add_measurement">Ölçüm ekle</string>
<string name="label_share">Paylaş</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_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> <string name="info_new_data_duplicated">Aynı tarih ve saatte ölçüm zaten var</string>

View File

@@ -72,7 +72,6 @@
<string name="error_exporting">Lỗi khi xuất</string> <string name="error_exporting">Lỗi khi xuất</string>
<string name="error_importing">Lỗi khi nhập</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_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_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_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> <string name="error_goal_weight_required">Lỗi: Trọng lượng mục tiêu là bắt buộc</string>

View File

@@ -72,7 +72,6 @@
<string name="error_exporting">滙出出錯</string> <string name="error_exporting">滙出出錯</string>
<string name="error_importing">滙入出錯</string> <string name="error_importing">滙入出錯</string>
<string name="error_user_name_required">錯誤: 必須輸入名字</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_height_required">錯誤: 必須輸入身高</string>
<string name="error_initial_weight_required">錯誤: 必須輸入初始體重</string> <string name="error_initial_weight_required">錯誤: 必須輸入初始體重</string>
<string name="error_goal_weight_required">錯誤: 必須輸入目標體重</string> <string name="error_goal_weight_required">錯誤: 必須輸入目標體重</string>

View File

@@ -75,7 +75,6 @@
<string name="error_exporting">Error exporting</string> <string name="error_exporting">Error exporting</string>
<string name="error_importing">Error importing</string> <string name="error_importing">Error importing</string>
<string name="error_user_name_required">Error: Name required</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_height_required">Error: Height required</string>
<string name="error_initial_weight_required">Error: Initial weight required</string> <string name="error_initial_weight_required">Error: Initial weight required</string>
<string name="error_goal_weight_required">Error: Goal weight required</string> <string name="error_goal_weight_required">Error: Goal weight required</string>

View File

@@ -24,9 +24,12 @@ Initialization
2. Write: `<alt sb 6> 01` 2. Write: `<alt sb 6> 01`
3. Notification: `<alt sb 6> 00 20` 3. Notification: `<alt sb 6> 00 20`
4. Write: `<alt sb 9> <timestamp>` 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>` 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 * `<battery>`: battery level
* `<wthr>`: weight threshold (unit g / 100) * `<wthr>`: weight threshold (unit g / 100)
* `<fthr>`: fat threshold * `<fthr>`: fat threshold
@@ -40,6 +43,11 @@ Initialization
1. Write: `<sb> 4e <wthr> <fthr>` 1. Write: `<sb> 4e <wthr> <fthr>`
2. Notification: `<sb> f0 4e 00` 2. Notification: `<sb> f0 4e 00`
Thresholds in original app:
* 0x28 0xdc
* 0x14 0xdc
* 0x0a 0x14
### Set unit ### Set unit
@@ -122,7 +130,7 @@ Measurements
4. Write: `<sb> f1 42 <count> <current>` 4. Write: `<sb> f1 42 <count> <current>`
5. Goto 3 if `<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 ### Delete saved measurements