1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-08-19 06:51:57 +02:00

Add BluetoothCultSmartScalePro driver implementation and test suite (#1127)

* Add BluetoothCultSmartScalePro driver implementation and test suite

- Implemented BluetoothCultSmartScalePro class for handling communication with the Cult Smart Scale Pro device.
- Added methods for reading device information, battery status, enabling notifications, and handling measurement data.
- Implemented weight and body composition data parsing with multiple strategies for robustness.
- Added comprehensive logging for debugging and connection management.
- Created a test suite (TestCultDriver) to validate weight and body composition parsing logic without Android dependencies.

* unnecessary files deleted

---------

Co-authored-by: oliexdev <olie.xdev@goooglemail.com>
This commit is contained in:
Fiend_Star
2025-06-07 12:27:45 +05:30
committed by GitHub
parent 28a57770be
commit 8fe8143039
2 changed files with 891 additions and 0 deletions

View File

@@ -0,0 +1,888 @@
/* Copyright (C) 2024
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth;
import android.content.Context;
import com.health.openscale.R;
import com.health.openscale.core.OpenScale;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import java.util.Date;
import java.util.UUID;
import timber.log.Timber;
public class BluetoothCultSmartScalePro extends BluetoothCommunication {
// Standard services
private final UUID DEVICE_INFORMATION_SERVICE = UUID.fromString("0000180A-0000-1000-8000-00805F9B34FB");
private final UUID BATTERY_SERVICE = UUID.fromString("0000180F-0000-1000-8000-00805F9B34FB");
// Custom Cult Smart Scale Pro Service (using Bluetooth Base UUID pattern)
private final UUID CULT_SCALE_SERVICE = UUID.fromString("0000FFF0-0000-1000-8000-00805F9B34FB");
// Standard Characteristics
private final UUID MANUFACTURER_NAME_CHARACTERISTIC = UUID.fromString("00002A29-0000-1000-8000-00805F9B34FB");
private final UUID MODEL_NUMBER_CHARACTERISTIC = UUID.fromString("00002A24-0000-1000-8000-00805F9B34FB");
private final UUID FIRMWARE_REVISION_CHARACTERISTIC = UUID.fromString("00002A26-0000-1000-8000-00805F9B34FB");
private final UUID BATTERY_LEVEL_CHARACTERISTIC = UUID.fromString("00002A19-0000-1000-8000-00805F9B34FB");
// Custom Characteristics for scale-specific functionality
private final UUID MEASUREMENT_CHARACTERISTIC_FFF1 = UUID.fromString("0000FFF1-0000-1000-8000-00805F9B34FB"); // Weight measurement data (WRITE, NOTIFY)
private final UUID CONTROL_CHARACTERISTIC_FFF2 = UUID.fromString("0000FFF2-0000-1000-8000-00805F9B34FB"); // Device control/config (WRITE_NO_RESPONSE, INDICATE)
private final UUID STATUS_CHARACTERISTIC_FFF4 = UUID.fromString("0000FFF4-0000-1000-8000-00805F9B34FB"); // Status monitoring (NOTIFY)
// Connection and measurement state management
private boolean measurementComplete = false;
private boolean deviceConfigured = false;
private long connectionStartTime = 0;
private int retryCount = 0;
private static final int MAX_RETRIES = 3;
private static final long CONNECTION_TIMEOUT_MS = 30000; // 30 seconds
private static final long MEASUREMENT_TIMEOUT_MS = 60000; // 60 seconds
// Device information storage
private String deviceManufacturer = "";
private String deviceModel = "";
private String firmwareVersion = "";
private int batteryLevel = -1;
// Measurement unit preferences
private enum WeightUnit {
KILOGRAMS(0x00, "kg"),
POUNDS(0x01, "lb"),
STONES_POUNDS(0x02, "st:lb");
private final byte value;
private final String symbol;
WeightUnit(int value, String symbol) {
this.value = (byte) value;
this.symbol = symbol;
}
public byte getValue() { return value; }
public String getSymbol() { return symbol; }
}
private WeightUnit preferredWeightUnit = WeightUnit.KILOGRAMS;
public BluetoothCultSmartScalePro(Context context) {
super(context);
resetDeviceState();
}
/**
* Reset all device state variables to initial values
* Called on connection start and error recovery
*/
private void resetDeviceState() {
measurementComplete = false;
deviceConfigured = false;
connectionStartTime = 0;
retryCount = 0;
deviceManufacturer = "";
deviceModel = "";
firmwareVersion = "";
batteryLevel = -1;
// Set preferred weight unit based on user preferences
ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser();
if (selectedUser != null) {
switch (selectedUser.getScaleUnit()) {
case LB:
preferredWeightUnit = WeightUnit.POUNDS;
break;
case ST:
preferredWeightUnit = WeightUnit.STONES_POUNDS;
break;
default:
preferredWeightUnit = WeightUnit.KILOGRAMS;
break;
}
}
Timber.d("Device state reset, preferred unit: %s", preferredWeightUnit.getSymbol());
}
/**
* Check if connection or measurement has timed out
* @return true if timeout occurred
*/
private boolean checkTimeout() {
if (connectionStartTime == 0) return false;
long currentTime = System.currentTimeMillis();
long elapsedTime = currentTime - connectionStartTime;
if (!measurementComplete && elapsedTime > MEASUREMENT_TIMEOUT_MS) {
Timber.w("Measurement timeout after %d ms", elapsedTime);
sendMessage(R.string.info_scale_not_ready, 0);
return true;
}
if (!deviceConfigured && elapsedTime > CONNECTION_TIMEOUT_MS) {
Timber.w("Connection timeout after %d ms", elapsedTime);
sendMessage(R.string.info_bluetooth_connection_error, 0);
return true;
}
return false;
}
@Override
public String driverName() {
return "Cult Smart Scale Pro";
}
@Override
protected boolean onNextStep(int stepNr) {
// Check for timeouts before proceeding with any step
if (checkTimeout()) {
Timber.w("Timeout detected, aborting step %d", stepNr);
return false;
}
switch (stepNr) {
case 0:
// Step 1: Read device information for compatibility verification
Timber.d("Step 0: Reading device information service");
resetDeviceState(); // Reset state at start of new connection
return readDeviceInformation();
case 1:
// Step 2: Read battery status for power monitoring
Timber.d("Step 1: Reading battery status");
return readBatteryStatus();
case 2:
// Step 3: Enable notifications on FFF1 (measurement data)
Timber.d("Step 2: Enabling notifications on measurement characteristic FFF1");
return enableNotifications(MEASUREMENT_CHARACTERISTIC_FFF1);
case 3:
// Step 4: Enable indications on FFF2 (control responses)
Timber.d("Step 3: Enabling indications on control characteristic FFF2");
return enableIndications(CONTROL_CHARACTERISTIC_FFF2);
case 4:
// Step 5: Enable notifications on FFF4 (status monitoring)
Timber.d("Step 4: Enabling notifications on status characteristic FFF4");
return enableNotifications(STATUS_CHARACTERISTIC_FFF4);
case 5:
// Step 6: Send user profile configuration
Timber.d("Step 5: Sending user profile configuration");
return sendUserProfile();
case 6:
// Step 7: Start measurement session
Timber.d("Step 6: Starting measurement session");
return startMeasurement();
default:
return false;
}
}
private boolean readDeviceInformation() {
try {
connectionStartTime = System.currentTimeMillis();
// Read manufacturer name to verify device compatibility
byte[] manufacturerData = readBytes(DEVICE_INFORMATION_SERVICE, MANUFACTURER_NAME_CHARACTERISTIC);
if (manufacturerData != null && manufacturerData.length > 0) {
deviceManufacturer = new String(manufacturerData).trim();
Timber.d("Device manufacturer: %s", deviceManufacturer);
} else {
Timber.w("Failed to read manufacturer name");
}
// Read model number for device identification
byte[] modelData = readBytes(DEVICE_INFORMATION_SERVICE, MODEL_NUMBER_CHARACTERISTIC);
if (modelData != null && modelData.length > 0) {
deviceModel = new String(modelData).trim();
Timber.d("Device model: %s", deviceModel);
} else {
Timber.w("Failed to read model number");
}
// Read firmware version for compatibility checks
byte[] firmwareData = readBytes(DEVICE_INFORMATION_SERVICE, FIRMWARE_REVISION_CHARACTERISTIC);
if (firmwareData != null && firmwareData.length > 0) {
firmwareVersion = new String(firmwareData).trim();
Timber.d("Firmware version: %s", firmwareVersion);
} else {
Timber.w("Failed to read firmware revision");
}
return true;
} catch (Exception e) {
Timber.e(e, "Error reading device information");
return false;
}
}
private boolean readBatteryStatus() {
try {
byte[] batteryData = readBytes(BATTERY_SERVICE, BATTERY_LEVEL_CHARACTERISTIC);
if (batteryData != null && batteryData.length > 0) {
batteryLevel = batteryData[0] & 0xFF;
Timber.d("Battery level: %d%%", batteryLevel);
if (batteryLevel < 20) {
sendMessage(R.string.info_scale_low_battery, batteryLevel);
Timber.w("Low battery warning: %d%%", batteryLevel);
}
return true;
} else {
Timber.w("Failed to read battery level");
return false;
}
} catch (Exception e) {
Timber.e(e, "Error reading battery status");
return false;
}
}
private boolean enableNotifications(UUID characteristic) {
try {
// Enable notifications using the service - setNotificationOn returns boolean
return setNotificationOn(CULT_SCALE_SERVICE, characteristic);
} catch (Exception e) {
Timber.e(e, "Error enabling notifications for characteristic: %s", characteristic);
return false;
}
}
private boolean enableIndications(UUID characteristic) {
try {
// Enable indications using the service - setIndicationOn returns void
setIndicationOn(CULT_SCALE_SERVICE, characteristic);
return true;
} catch (Exception e) {
Timber.e(e, "Error enabling indications for characteristic: %s", characteristic);
return false;
}
}
private boolean sendUserProfile() {
try {
ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser();
// Prepare user profile data with comprehensive configuration
byte[] userProfile = new byte[10];
userProfile[0] = (byte) 0xFE; // Start marker for user profile
userProfile[1] = (byte) selectedUser.getId(); // User ID
userProfile[2] = (byte) selectedUser.getAge(); // Age
// Convert height to integer for bit operations
int heightCm = (int) selectedUser.getBodyHeight();
userProfile[3] = (byte) (heightCm & 0xFF); // Height (low byte)
userProfile[4] = (byte) ((heightCm >> 8) & 0xFF); // Height (high byte)
userProfile[5] = (byte) (selectedUser.getGender().isMale() ? 1 : 0); // Gender (1=male, 0=female)
userProfile[6] = preferredWeightUnit.getValue(); // Measurement unit (use preferred unit)
userProfile[7] = (byte) 0x00; // Reserved
// Calculate XOR checksum for data integrity
byte checksum = 0;
for (int i = 0; i < 8; i++) {
checksum ^= userProfile[i];
}
userProfile[8] = checksum;
userProfile[9] = (byte) 0xFF; // End marker
Timber.d("Sending user profile for %s: unit=%s, [%s]",
selectedUser.getUserName(), preferredWeightUnit.getSymbol(), byteInHex(userProfile));
boolean success = writeBytes(CONTROL_CHARACTERISTIC_FFF2, userProfile);
if (success) {
deviceConfigured = true;
Timber.d("User profile sent successfully");
} else if (retryCount < MAX_RETRIES) {
retryCount++;
Timber.w("User profile send failed, retry %d/%d", retryCount, MAX_RETRIES);
// Retry will happen in next onNextStep call
}
return success;
} catch (Exception e) {
Timber.e(e, "Error sending user profile");
return false;
}
}
private boolean startMeasurement() {
try {
// Check if device is properly configured before starting measurement
if (!deviceConfigured) {
Timber.w("Device not properly configured, cannot start measurement");
return false;
}
// Check for connection timeout
long currentTime = System.currentTimeMillis();
if (connectionStartTime > 0 && (currentTime - connectionStartTime) > CONNECTION_TIMEOUT_MS) {
Timber.w("Connection timeout exceeded (%d ms), aborting measurement",
currentTime - connectionStartTime);
sendMessage(R.string.info_bluetooth_connection_error, 0);
return false;
}
// Send measurement start command with longer connection interval for power optimization
byte[] startCommand = {(byte) 0xFD, (byte) 0x01, (byte) 0x00, (byte) 0xFC}; // Start measurement command
Timber.d("Sending measurement start command: [%s]", byteInHex(startCommand));
boolean success = writeBytes(CONTROL_CHARACTERISTIC_FFF2, startCommand);
if (success) {
sendMessage(R.string.info_measuring, 0);
Timber.i("Measurement session started successfully");
}
return success;
} catch (Exception e) {
Timber.e(e, "Error starting measurement");
return false;
}
}
@Override
public void onBluetoothNotify(UUID characteristic, byte[] value) {
final byte[] data = value;
if (data == null || data.length == 0) {
Timber.w("Received empty notification from %s", characteristic);
return;
}
// Log detailed debug information for troubleshooting
logDebugInfo("NOTIFY-" + characteristic.toString().substring(4, 8).toUpperCase(), data);
Timber.d("Received notification from %s: [%s]", characteristic, byteInHex(data));
// Handle notifications based on characteristic purpose
if (characteristic.equals(MEASUREMENT_CHARACTERISTIC_FFF1)) {
// FFF1: Weight measurement data transmission (WRITE + NOTIFY)
handleWeightMeasurementData(data);
} else if (characteristic.equals(CONTROL_CHARACTERISTIC_FFF2)) {
// FFF2: Device control confirmations (WRITE_NO_RESPONSE + INDICATE)
handleDeviceControlResponse(data);
} else if (characteristic.equals(STATUS_CHARACTERISTIC_FFF4)) {
// FFF4: Status monitoring (NOTIFY)
handleStatusNotification(data);
} else {
Timber.w("Received notification from unknown characteristic: %s", characteristic);
}
}
/**
* Handle weight measurement data from FFF1 characteristic
* This characteristic transmits actual weight readings when scale stabilizes
*/
private void handleWeightMeasurementData(byte[] data) {
Timber.d("Processing weight measurement data: [%s]", byteInHex(data));
// Check for weight measurement indicators
if (data.length >= 8) {
// Common weight data patterns - may start with specific command bytes
if (data[0] == (byte)0xCF || data[0] == (byte)0xFD || data[0] == (byte)0xAA) {
parseWeightData(data);
} else {
// Try parsing anyway - some scales don't use command prefixes
parseWeightData(data);
}
}
}
/**
* Handle device control responses from FFF2 characteristic
* This characteristic confirms successful configuration commands
*/
private void handleDeviceControlResponse(byte[] data) {
Timber.d("Processing device control response: [%s]", byteInHex(data));
if (data.length >= 2) {
byte command = data[0];
byte status = data[1];
switch (command) {
case (byte)0x37: // Configuration command response
if (status == (byte)0x00) {
Timber.d("Device configuration successful");
} else {
Timber.w("Device configuration failed with status: 0x%02X", status);
}
break;
case (byte)0x50: // Measurement start response
if (status == (byte)0x00) {
Timber.d("Measurement started successfully");
sendMessage(R.string.info_measuring, 0);
} else {
Timber.w("Failed to start measurement, status: 0x%02X", status);
}
break;
default:
Timber.d("Unknown command response: 0x%02X, status: 0x%02X", command, status);
}
}
}
/**
* Handle status notifications from FFF4 characteristic
* This provides battery levels, measurement progress, error conditions
*/
private void handleStatusNotification(byte[] data) {
Timber.d("Processing status notification: [%s]", byteInHex(data));
if (data.length >= 3) {
byte statusType = data[0];
switch (statusType) {
case (byte)0xBB: // Body composition data
if (data.length >= 20) {
parseBodyCompositionData(data);
}
break;
case (byte)0xBA: // Battery status
int batteryLevel = data[1] & 0xFF;
Timber.d("Battery level: %d%%", batteryLevel);
if (batteryLevel < 20) {
sendMessage(R.string.info_scale_low_battery, batteryLevel);
}
break;
case (byte)0xBC: // Measurement progress
byte progress = data[1];
if (progress == (byte)0x01) {
Timber.d("Measurement in progress...");
sendMessage(R.string.info_measuring, 0);
} else if (progress == (byte)0x02) {
Timber.d("Measurement complete");
}
break;
case (byte)0xBE: // Error conditions
byte errorCode = data[1];
Timber.w("Scale error reported: 0x%02X", errorCode);
sendMessage(R.string.info_scale_not_ready, 0);
break;
default:
Timber.d("Unknown status type: 0x%02X", statusType);
}
}
}
/**
* Parse weight data using multiple encoding patterns common in smart scales
* Tries different byte positions, endianness, and scaling factors
*/
private void parseWeightData(byte[] data) {
try {
float weight = 0.0f;
boolean weightFound = false;
// Try multiple parsing strategies common in BLE scales
if (data.length >= 6) {
// Strategy 1: Little-endian 16-bit at positions 3-4, scale by 100
if (!weightFound) {
int weightRaw = (data[3] & 0xFF) | ((data[4] & 0xFF) << 8);
weight = weightRaw / 100.0f;
if (weight >= 10.0f && weight <= 300.0f) {
weightFound = true;
Timber.d("Weight found using strategy 1 (LE pos 3-4, /100): %.2f kg", weight);
}
}
// Strategy 2: Big-endian 16-bit at positions 3-4, scale by 100
if (!weightFound) {
int weightRaw = ((data[3] & 0xFF) << 8) | (data[4] & 0xFF);
weight = weightRaw / 100.0f;
if (weight >= 10.0f && weight <= 300.0f) {
weightFound = true;
Timber.d("Weight found using strategy 2 (BE pos 3-4, /100): %.2f kg", weight);
}
}
// Strategy 3: Little-endian 16-bit at positions 2-3, scale by 100
if (!weightFound) {
int weightRaw = (data[2] & 0xFF) | ((data[3] & 0xFF) << 8);
weight = weightRaw / 100.0f;
if (weight >= 10.0f && weight <= 300.0f) {
weightFound = true;
Timber.d("Weight found using strategy 3 (LE pos 2-3, /100): %.2f kg", weight);
}
}
// Strategy 4: Try scale factor of 10 instead of 100
if (!weightFound) {
int weightRaw = (data[3] & 0xFF) | ((data[4] & 0xFF) << 8);
weight = weightRaw / 10.0f;
if (weight >= 10.0f && weight <= 300.0f) {
weightFound = true;
Timber.d("Weight found using strategy 4 (LE pos 3-4, /10): %.2f kg", weight);
}
}
// Strategy 5: Try positions 1-2 with scale factor 100
if (!weightFound && data.length >= 5) {
int weightRaw = (data[1] & 0xFF) | ((data[2] & 0xFF) << 8);
weight = weightRaw / 100.0f;
if (weight >= 10.0f && weight <= 300.0f) {
weightFound = true;
Timber.d("Weight found using strategy 5 (LE pos 1-2, /100): %.2f kg", weight);
}
}
}
if (weightFound) {
ScaleMeasurement scaleMeasurement = new ScaleMeasurement();
// Weight is always parsed in kg, convert if needed for display
scaleMeasurement.setWeight(weight);
scaleMeasurement.setDateTime(new Date());
// Log both original and converted weights for debugging
float displayWeight = convertWeight(weight);
Timber.i("Successfully parsed weight: %.2f kg (%.2f %s) from data: [%s]",
weight, displayWeight, preferredWeightUnit.getSymbol(), byteInHex(data));
// Only add simple weight measurement if we haven't completed a full body composition measurement
if (!measurementComplete) {
addScaleMeasurement(scaleMeasurement);
measurementComplete = true;
sendMessage(R.string.info_scale_ready, 0);
}
} else {
Timber.w("Unable to extract valid weight from data: [%s]", byteInHex(data));
// Log all attempted values for debugging
for (int i = 0; i < Math.min(data.length - 1, 5); i++) {
int raw1 = (data[i] & 0xFF) | ((data[i + 1] & 0xFF) << 8);
int raw2 = ((data[i] & 0xFF) << 8) | (data[i + 1] & 0xFF);
Timber.d("Position %d: LE=%d (%.2f/%.1f), BE=%d (%.2f/%.1f)",
i, raw1, raw1/100.0f, raw1/10.0f, raw2, raw2/100.0f, raw2/10.0f);
}
}
} catch (Exception e) {
Timber.e(e, "Error parsing weight data: [%s]", byteInHex(data));
}
}
/**
* Parse comprehensive body composition data from status notifications
* Extracts weight, body fat, water, muscle, bone mass, and visceral fat
*/
private void parseBodyCompositionData(byte[] data) {
try {
ScaleMeasurement scaleMeasurement = new ScaleMeasurement();
boolean hasValidWeight = false;
int validMetrics = 0;
Timber.d("Parsing body composition data (%d bytes): [%s]", data.length, byteInHex(data));
if (data.length >= 20) {
// Extract weight using multiple strategies
float weight = 0.0f;
// Try different weight positions common in body composition packets
int[] weightPositions = {2, 4, 6, 8}; // Common positions for weight data
for (int pos : weightPositions) {
if (pos + 1 < data.length && !hasValidWeight) {
// Little-endian
int weightRaw = (data[pos] & 0xFF) | ((data[pos + 1] & 0xFF) << 8);
weight = weightRaw / 100.0f;
if (weight >= 10.0f && weight <= 300.0f) {
scaleMeasurement.setWeight(weight);
hasValidWeight = true;
Timber.d("Extracted weight: %.2f kg (position %d, LE)", weight, pos);
break;
}
// Big-endian if little-endian failed
weightRaw = ((data[pos] & 0xFF) << 8) | (data[pos + 1] & 0xFF);
weight = weightRaw / 100.0f;
if (weight >= 10.0f && weight <= 300.0f) {
scaleMeasurement.setWeight(weight);
hasValidWeight = true;
Timber.d("Extracted weight: %.2f kg (position %d, BE)", weight, pos);
break;
}
}
}
if (hasValidWeight) {
// Extract body composition metrics
// Try different common layouts for body composition data
// Layout 1: Sequential 16-bit values after weight
int baseOffset = 6; // Start after weight data
if (baseOffset + 10 < data.length) {
float fat = extractPercentageValue(data, baseOffset, baseOffset + 1);
float water = extractPercentageValue(data, baseOffset + 2, baseOffset + 3);
float muscle = extractPercentageValue(data, baseOffset + 4, baseOffset + 5);
float bone = extractPercentageValue(data, baseOffset + 6, baseOffset + 7) / 10.0f;
float visceral = extractPercentageValue(data, baseOffset + 8, baseOffset + 9) / 10.0f;
// Validate and set metrics
if (fat > 0.0f && fat <= 50.0f) {
scaleMeasurement.setFat(fat);
validMetrics++;
Timber.d("Extracted fat: %.1f%% (layout 1)", fat);
}
if (water > 30.0f && water <= 80.0f) {
scaleMeasurement.setWater(water);
validMetrics++;
Timber.d("Extracted water: %.1f%% (layout 1)", water);
}
if (muscle > 10.0f && muscle <= 70.0f) {
scaleMeasurement.setMuscle(muscle);
validMetrics++;
Timber.d("Extracted muscle: %.1f%% (layout 1)", muscle);
}
if (bone > 0.5f && bone <= 8.0f) {
scaleMeasurement.setBone(bone);
validMetrics++;
Timber.d("Extracted bone: %.2f kg (layout 1)", bone);
}
if (visceral > 0.0f && visceral <= 30.0f) {
scaleMeasurement.setVisceralFat(visceral);
validMetrics++;
Timber.d("Extracted visceral fat: %.1f (layout 1)", visceral);
}
}
// If layout 1 didn't yield good results, try layout 2 (different spacing)
if (validMetrics < 3 && data.length >= 18) {
Timber.d("Trying alternative body composition layout...");
validMetrics = 0; // Reset counter
// Layout 2: Different positioning, some metrics might be single bytes
float fat2 = (data[10] & 0xFF) / 10.0f;
float water2 = (data[12] & 0xFF) / 10.0f;
float muscle2 = (data[14] & 0xFF) / 10.0f;
float bone2 = (data[16] & 0xFF) / 100.0f;
float visceral2 = (data[17] & 0xFF) / 10.0f;
if (fat2 > 0.0f && fat2 <= 50.0f) {
scaleMeasurement.setFat(fat2);
validMetrics++;
Timber.d("Extracted fat: %.1f%% (layout 2)", fat2);
}
if (water2 > 30.0f && water2 <= 80.0f) {
scaleMeasurement.setWater(water2);
validMetrics++;
Timber.d("Extracted water: %.1f%% (layout 2)", water2);
}
if (muscle2 > 10.0f && muscle2 <= 70.0f) {
scaleMeasurement.setMuscle(muscle2);
validMetrics++;
Timber.d("Extracted muscle: %.1f%% (layout 2)", muscle2);
}
if (bone2 > 0.5f && bone2 <= 8.0f) {
scaleMeasurement.setBone(bone2);
validMetrics++;
Timber.d("Extracted bone: %.2f kg (layout 2)", bone2);
}
if (visceral2 > 0.0f && visceral2 <= 30.0f) {
scaleMeasurement.setVisceralFat(visceral2);
validMetrics++;
Timber.d("Extracted visceral fat: %.1f (layout 2)", visceral2);
}
}
}
}
// Save measurement if we have valid weight and at least some body composition data
if (hasValidWeight && validMetrics >= 2) {
scaleMeasurement.setDateTime(new Date());
float displayWeight = convertWeight(scaleMeasurement.getWeight());
Timber.i("Successfully parsed body composition: weight=%.2f kg (%.2f %s), %d metrics",
scaleMeasurement.getWeight(), displayWeight, preferredWeightUnit.getSymbol(), validMetrics);
addScaleMeasurement(scaleMeasurement);
measurementComplete = true;
sendMessage(R.string.info_scale_ready, 0);
} else if (hasValidWeight) {
// Save weight-only measurement if no body composition data is valid
scaleMeasurement.setDateTime(new Date());
float displayWeight = convertWeight(scaleMeasurement.getWeight());
Timber.i("Parsed weight-only measurement: %.2f kg (%.2f %s)",
scaleMeasurement.getWeight(), displayWeight, preferredWeightUnit.getSymbol());
addScaleMeasurement(scaleMeasurement);
measurementComplete = true;
sendMessage(R.string.info_scale_ready, 0);
} else {
Timber.w("No valid body composition data found in %d-byte packet: [%s]",
data.length, byteInHex(data));
}
} catch (Exception e) {
Timber.e(e, "Error parsing body composition data: [%s]", byteInHex(data));
}
}
private float extractPercentageValue(byte[] data, int lowByte, int highByte) {
if (lowByte >= data.length || highByte >= data.length) {
return 0.0f;
}
// Try little-endian first
int value = (data[lowByte] & 0xFF) | ((data[highByte] & 0xFF) << 8);
float result = value / 10.0f;
// If the result seems unreasonable, try big-endian
if (result <= 0.0f || result > 100.0f) {
value = ((data[lowByte] & 0xFF) << 8) | (data[highByte] & 0xFF);
result = value / 10.0f;
}
return result;
}
@Override
public void onBluetoothConnect() {
super.onBluetoothConnect();
resetDeviceState();
Timber.i("Connected to Cult Smart Scale Pro (%s %s)", deviceManufacturer, deviceModel);
}
@Override
public void onBluetoothDisconnect() {
super.onBluetoothDisconnect();
Timber.i("Disconnected from Cult Smart Scale Pro");
// If measurement was not completed, show appropriate message
if (!measurementComplete && connectionStartTime > 0) {
sendMessage(R.string.info_bluetooth_connection_lost, 0);
}
}
/**
* Handle connection errors and implement retry logic
*/
@Override
public void onBluetoothConnectionError() {
super.onBluetoothConnectionError();
if (retryCount < MAX_RETRIES) {
retryCount++;
Timber.w("Connection error, attempting retry %d/%d", retryCount, MAX_RETRIES);
sendMessage(R.string.info_bluetooth_try_connection, retryCount);
} else {
Timber.e("Maximum retries exceeded, giving up connection");
sendMessage(R.string.info_bluetooth_connection_error, 0);
resetDeviceState();
}
}
/**
* Convert weight from kilograms to user's preferred unit
*/
private float convertWeight(float weightKg) {
switch (preferredWeightUnit) {
case POUNDS:
return weightKg * 2.20462f;
case STONES_POUNDS:
return weightKg * 0.157473f; // Convert to stones (pounds handled separately)
default:
return weightKg;
}
}
/**
* Get device status summary for debugging and user information
*/
public String getDeviceStatus() {
StringBuilder status = new StringBuilder();
status.append("Cult Smart Scale Pro Status:\n");
status.append("Manufacturer: ").append(deviceManufacturer.isEmpty() ? "Unknown" : deviceManufacturer).append("\n");
status.append("Model: ").append(deviceModel.isEmpty() ? "Unknown" : deviceModel).append("\n");
status.append("Firmware: ").append(firmwareVersion.isEmpty() ? "Unknown" : firmwareVersion).append("\n");
status.append("Battery: ").append(batteryLevel >= 0 ? batteryLevel + "%" : "Unknown").append("\n");
status.append("Configured: ").append(deviceConfigured ? "Yes" : "No").append("\n");
status.append("Measurement Complete: ").append(measurementComplete ? "Yes" : "No").append("\n");
status.append("Weight Unit: ").append(preferredWeightUnit.getSymbol()).append("\n");
if (connectionStartTime > 0) {
long elapsed = System.currentTimeMillis() - connectionStartTime;
status.append("Connection Time: ").append(elapsed / 1000).append("s\n");
}
return status.toString();
}
/**
* Comprehensive logging method for debugging BLE communication
* Logs device state, connection info, and recent data patterns
*/
private void logDebugInfo(String context, byte[] data) {
if (data != null) {
Timber.d("[%s] Data (%d bytes): [%s]", context, data.length, byteInHex(data));
// Log human-readable interpretation of common patterns
if (data.length >= 4) {
StringBuilder interpretation = new StringBuilder();
interpretation.append("[").append(context).append("] Possible interpretations: ");
// Check for common command/response patterns
if (data[0] == (byte)0xCF || data[0] == (byte)0xFD || data[0] == (byte)0xAA) {
interpretation.append("Command/Response pattern; ");
}
// Check for weight patterns (try multiple positions)
for (int i = 0; i < Math.min(data.length - 1, 4); i++) {
int weightRaw = (data[i] & 0xFF) | ((data[i + 1] & 0xFF) << 8);
float weight100 = weightRaw / 100.0f;
float weight10 = weightRaw / 10.0f;
if (weight100 >= 10.0f && weight100 <= 300.0f) {
interpretation.append(String.format("Weight@pos%d=%.1fkg; ", i, weight100));
} else if (weight10 >= 10.0f && weight10 <= 300.0f) {
interpretation.append(String.format("Weight@pos%d=%.1fkg(/10); ", i, weight10));
}
}
// Check for percentage patterns (body composition)
for (int i = 0; i < Math.min(data.length - 1, 6); i++) {
int percentRaw = (data[i] & 0xFF) | ((data[i + 1] & 0xFF) << 8);
float percent = percentRaw / 10.0f;
if (percent > 5.0f && percent <= 100.0f) {
interpretation.append(String.format("Percent@pos%d=%.1f%%; ", i, percent));
}
}
Timber.d(interpretation.toString());
}
}
// Log device state every 10th debug call to avoid spam
if (++debugCallCount % 10 == 0) {
Timber.d("Device State Summary:\n%s", getDeviceStatus());
}
}
// Debug call counter as instance variable
private int debugCallCount = 0;
}

View File

@@ -46,6 +46,9 @@ public class BluetoothFactory {
if (name.equals("openScale".toLowerCase(Locale.US))) {
return new BluetoothCustomOpenScale(context);
}
if (name.equals("Cult Smart Scale Pro".toLowerCase(Locale.US))) {
return new BluetoothCultSmartScalePro(context);
}
if (name.equals("Mengii".toLowerCase(Locale.US))) {
return new BluetoothDigooDGSO38H(context);
}