/* Copyright (C) 2024 Severin von Wnuck-Lipinski This file is part of Gadgetbridge. Gadgetbridge is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Gadgetbridge 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 Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.service.devices.miscale; import android.bluetooth.BluetoothGatt; import android.bluetooth.BluetoothGattCharacteristic; import android.content.SharedPreferences; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.GregorianCalendar; import java.util.List; import java.util.UUID; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo; import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandService; import nodomain.freeyourgadget.gadgetbridge.devices.miscale.MiScaleSampleProvider; import nodomain.freeyourgadget.gadgetbridge.entities.MiScaleWeightSample; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; import nodomain.freeyourgadget.gadgetbridge.service.btle.GattCharacteristic; import nodomain.freeyourgadget.gadgetbridge.service.btle.GattService; import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction; import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.deviceinfo.DeviceInfo; import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.deviceinfo.DeviceInfoProfile; import nodomain.freeyourgadget.gadgetbridge.service.serial.GBDeviceProtocol; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.*; public class MiSmartScaleDeviceSupport extends AbstractBTLEDeviceSupport { private static final Logger LOG = LoggerFactory.getLogger(MiSmartScaleDeviceSupport.class); private static final UUID UUID_CHARACTERISTIC_CONFIG = UUID.fromString("00001542-0000-3512-2118-0009af100700"); private static final UUID UUID_CHARACTERISTIC_WEIGHT_HISTORY = UUID.fromString("00002a2f-0000-3512-2118-0009af100700"); // There's unfortunately no way to query the config options, they can only be set private static final byte CFG_WEIGHT_UNIT = (byte)0x04; private static final byte CFG_SMALL_OBJECTS = (byte)0x10; private static final byte CFG_RESET_HISTORY = (byte)0x12; private static final byte CMD_HISTORY_START = (byte)0x01; private static final byte CMD_HISTORY_QUERY = (byte)0x02; private static final byte CMD_HISTORY_COMPLETE = (byte)0x03; private static final byte CMD_HISTORY_END = (byte)0x04; // Threshold for small objects private static final int SMALL_OBJECT_MAX_WEIGHT = 10; private long userId = -1; private final DeviceInfoProfile deviceInfoProfile; public MiSmartScaleDeviceSupport() { super(LOG); // Get unique user ID for weight history querying try (DBHandler db = GBApplication.acquireDB()) { userId = DBHelper.getUser(db.getDaoSession()).getId(); } catch (Exception e) { LOG.error("Error acquiring database", e); } deviceInfoProfile = new DeviceInfoProfile<>(this); deviceInfoProfile.addListener(intent -> { if (!DeviceInfoProfile.ACTION_DEVICE_INFO.equals(intent.getAction())) return; DeviceInfo info = intent.getParcelableExtra(DeviceInfoProfile.EXTRA_DEVICE_INFO); if (info == null) return; GBDeviceEventVersionInfo event = new GBDeviceEventVersionInfo(); event.fwVersion = info.getSoftwareRevision(); event.hwVersion = info.getHardwareRevision(); handleGBDeviceEvent(event); }); addSupportedService(GattService.UUID_SERVICE_GENERIC_ACCESS); addSupportedService(GattService.UUID_SERVICE_GENERIC_ATTRIBUTE); addSupportedService(GattService.UUID_SERVICE_DEVICE_INFORMATION); addSupportedService(GattService.UUID_SERVICE_WEIGHT_SCALE); addSupportedService(UUID.fromString(MiBandService.UUID_SERVICE_WEIGHT_SERVICE)); addSupportedProfile(deviceInfoProfile); } @Override protected TransactionBuilder initializeDevice(TransactionBuilder builder) { builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext())); deviceInfoProfile.requestDeviceInfo(builder); if (GBApplication.getPrefs().getBoolean("datetime_synconconnect", true)) setTime(builder); builder.notify(getCharacteristic(GattCharacteristic.UUID_CHARACTERISTIC_WEIGHT_MEASUREMENT), true); builder.notify(getCharacteristic(UUID_CHARACTERISTIC_WEIGHT_HISTORY), true); // Query weight measurements saved by the scale sendHistoryCommand(builder, CMD_HISTORY_START, true); sendHistoryCommand(builder, CMD_HISTORY_QUERY, false); builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZED, getContext())); return builder; } @Override public boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { if (super.onCharacteristicChanged(gatt, characteristic)) return true; UUID uuid = characteristic.getUuid(); if (!uuid.equals(GattCharacteristic.UUID_CHARACTERISTIC_WEIGHT_MEASUREMENT) && !uuid.equals(UUID_CHARACTERISTIC_WEIGHT_HISTORY)) return false; byte[] data = characteristic.getValue(); if (data.length == 1 && data[0] == CMD_HISTORY_COMPLETE) { TransactionBuilder builder = createTransactionBuilder("ack"); // Acknowledge weight history reception sendHistoryCommand(builder, CMD_HISTORY_COMPLETE, false); sendHistoryCommand(builder, CMD_HISTORY_END, true); builder.notify(getCharacteristic(UUID_CHARACTERISTIC_WEIGHT_HISTORY), false); builder.queue(getQueue()); } else { ByteBuffer buf = ByteBuffer.wrap(characteristic.getValue()); List measurements = new ArrayList<>(); WeightMeasurement measurement = WeightMeasurement.decode(buf); // Weight history characteristic often has two measurements in one packet while (measurement != null) { measurements.add(measurement); measurement = WeightMeasurement.decode(buf); } saveMeasurements(measurements); } return true; } @Override public void onReset(int flags) { if ((flags & GBDeviceProtocol.RESET_FLAGS_FACTORY_RESET) == 0) return; try { TransactionBuilder builder = performInitialized("reset"); setConfigValue(builder, CFG_RESET_HISTORY, (byte)0x00); builder.queue(getQueue()); } catch (IOException e) { LOG.error("Error", e); } } @Override public void onSendConfiguration(String config) { SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(getDevice().getAddress()); try { TransactionBuilder builder = performInitialized("config"); if (config.equals(PREF_MISCALE_WEIGHT_UNIT)) { int unit = Integer.parseInt(prefs.getString(PREF_MISCALE_WEIGHT_UNIT, "0")); setConfigValue(builder, CFG_WEIGHT_UNIT, (byte)unit); } else if (config.equals(PREF_MISCALE_SMALL_OBJECTS)) { boolean enabled = prefs.getBoolean(PREF_MISCALE_SMALL_OBJECTS, false); setConfigValue(builder, CFG_SMALL_OBJECTS, enabled ? (byte)0x01: (byte)0x00); } builder.queue(getQueue()); } catch (IOException e) { LOG.error("Error", e); } } @Override public boolean useAutoConnect() { return false; } private void setTime(TransactionBuilder builder) { GregorianCalendar now = BLETypeConversions.createCalendar(); byte[] time = BLETypeConversions.calendarToCurrentTime(now, 0); builder.write(getCharacteristic(GattCharacteristic.UUID_CHARACTERISTIC_CURRENT_TIME), time); } private void setConfigValue(TransactionBuilder builder, byte config, byte value) { byte[] data = new byte[] { (byte)0x06, config, (byte)0x00, value }; builder.write(getCharacteristic(UUID_CHARACTERISTIC_CONFIG), data); } private void sendHistoryCommand(TransactionBuilder builder, byte cmd, boolean includeUserId) { ByteBuffer buf = ByteBuffer.allocate(includeUserId ? 5 : 1); buf.put(cmd); // The user ID is directly related to the account ID in the Zepp Life app if (includeUserId) buf.putInt((int)userId); builder.write(getCharacteristic(UUID_CHARACTERISTIC_WEIGHT_HISTORY), buf.array()); } private void saveMeasurements(List measurements) { SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(getDevice().getAddress()); boolean allowSmallObjects = prefs.getBoolean(PREF_MISCALE_SMALL_OBJECTS, false); try (DBHandler db = GBApplication.acquireDB()) { MiScaleSampleProvider provider = new MiScaleSampleProvider(getDevice(), db.getDaoSession()); List samples = new ArrayList<>(); Long userId = DBHelper.getUser(db.getDaoSession()).getId(); Long deviceId = DBHelper.getDevice(getDevice(), db.getDaoSession()).getId(); for (WeightMeasurement measurement : measurements) { // Skip measurements of small objects if not allowed if (!allowSmallObjects && measurement.getWeightKg() < SMALL_OBJECT_MAX_WEIGHT) continue; samples.add(new MiScaleWeightSample( measurement.getTimestamp().getTime(), deviceId, userId, measurement.getWeightKg() )); } provider.addSamples(samples); } catch (Exception e) { LOG.error("Error acquiring database", e); } } }