From bf75e0dd4a149788c56972a753b323d68506e065 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Rebelo?= Date: Tue, 9 Jan 2024 17:40:37 +0000 Subject: [PATCH] Mijia LYWSD: Handle and persist live and historic data samples Fetching is disabled for now, due to battery drain on both the phone and mijia device. --- .../gadgetbridge/daogen/GBDaoGenerator.java | 22 +- .../MijiaLywsdHistoricSampleProvider.java | 56 +++++ .../MijiaLywsdRealtimeSampleProvider.java | 56 +++++ .../mijia_lywsd/MijiaLywsdSupport.java | 222 ++++++++++++++++-- 4 files changed, 340 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/MijiaLywsdHistoricSampleProvider.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/MijiaLywsdRealtimeSampleProvider.java diff --git a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java index ce2001cd0..178f1cdbe 100644 --- a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java +++ b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java @@ -45,7 +45,7 @@ public class GBDaoGenerator { public static void main(String[] args) throws Exception { - final Schema schema = new Schema(67, MAIN_PACKAGE + ".entities"); + final Schema schema = new Schema(68, MAIN_PACKAGE + ".entities"); Entity userAttributes = addUserAttributes(schema); Entity user = addUserInfo(schema, userAttributes); @@ -108,6 +108,8 @@ public class GBDaoGenerator { addWena3Vo2Sample(schema, user, device); addWena3StressSample(schema, user, device); addFemometerVinca2TemperatureSample(schema, user, device); + addMijiaLywsdRealtimeSample(schema, user, device); + addMijiaLywsdHistoricSample(schema, user, device); addHuaweiActivitySample(schema, user, device); @@ -1122,4 +1124,22 @@ public class GBDaoGenerator { addTemperatureProperties(sample); return sample; } + + private static Entity addMijiaLywsdRealtimeSample(Schema schema, Entity user, Entity device) { + Entity sample = addEntity(schema, "MijiaLywsdRealtimeSample"); + addCommonTimeSampleProperties("AbstractTimeSample", sample, user, device); + sample.addFloatProperty(SAMPLE_TEMPERATURE).notNull(); + sample.addIntProperty("humidity"); + return sample; + } + + private static Entity addMijiaLywsdHistoricSample(Schema schema, Entity user, Entity device) { + Entity sample = addEntity(schema, "MijiaLywsdHistoricSample"); + addCommonTimeSampleProperties("AbstractTimeSample", sample, user, device); + sample.addFloatProperty("minTemperature"); + sample.addFloatProperty("maxTemperature"); + sample.addIntProperty("minHumidity"); + sample.addIntProperty("maxHumidity"); + return sample; + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/MijiaLywsdHistoricSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/MijiaLywsdHistoricSampleProvider.java new file mode 100644 index 000000000..6b39a26ff --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/MijiaLywsdHistoricSampleProvider.java @@ -0,0 +1,56 @@ +/* Copyright (C) 2023 José Rebelo + + 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.devices.mijia_lywsd; + +import androidx.annotation.NonNull; + +import de.greenrobot.dao.AbstractDao; +import de.greenrobot.dao.Property; +import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.MijiaLywsdHistoricSample; +import nodomain.freeyourgadget.gadgetbridge.entities.MijiaLywsdHistoricSampleDao; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; + +public class MijiaLywsdHistoricSampleProvider extends AbstractTimeSampleProvider { + public MijiaLywsdHistoricSampleProvider(final GBDevice device, final DaoSession session) { + super(device, session); + } + + @NonNull + @Override + public AbstractDao getSampleDao() { + return getSession().getMijiaLywsdHistoricSampleDao(); + } + + @NonNull + @Override + protected Property getTimestampSampleProperty() { + return MijiaLywsdHistoricSampleDao.Properties.Timestamp; + } + + @NonNull + @Override + protected Property getDeviceIdentifierSampleProperty() { + return MijiaLywsdHistoricSampleDao.Properties.DeviceId; + } + + @Override + public MijiaLywsdHistoricSample createSample() { + return new MijiaLywsdHistoricSample(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/MijiaLywsdRealtimeSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/MijiaLywsdRealtimeSampleProvider.java new file mode 100644 index 000000000..aa123bd58 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/mijia_lywsd/MijiaLywsdRealtimeSampleProvider.java @@ -0,0 +1,56 @@ +/* Copyright (C) 2023 José Rebelo + + 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.devices.mijia_lywsd; + +import androidx.annotation.NonNull; + +import de.greenrobot.dao.AbstractDao; +import de.greenrobot.dao.Property; +import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.MijiaLywsdRealtimeSample; +import nodomain.freeyourgadget.gadgetbridge.entities.MijiaLywsdRealtimeSampleDao; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; + +public class MijiaLywsdRealtimeSampleProvider extends AbstractTimeSampleProvider { + public MijiaLywsdRealtimeSampleProvider(final GBDevice device, final DaoSession session) { + super(device, session); + } + + @NonNull + @Override + public AbstractDao getSampleDao() { + return getSession().getMijiaLywsdRealtimeSampleDao(); + } + + @NonNull + @Override + protected Property getTimestampSampleProperty() { + return MijiaLywsdRealtimeSampleDao.Properties.Timestamp; + } + + @NonNull + @Override + protected Property getDeviceIdentifierSampleProperty() { + return MijiaLywsdRealtimeSampleDao.Properties.DeviceId; + } + + @Override + public MijiaLywsdRealtimeSample createSample() { + return new MijiaLywsdRealtimeSample(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/mijia_lywsd/MijiaLywsdSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/mijia_lywsd/MijiaLywsdSupport.java index 98a8176f7..76b8ede8a 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/mijia_lywsd/MijiaLywsdSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/mijia_lywsd/MijiaLywsdSupport.java @@ -19,25 +19,36 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.mijia_lywsd; import android.bluetooth.BluetoothGatt; import android.bluetooth.BluetoothGattCharacteristic; -import android.content.Intent; import android.widget.Toast; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; import java.util.Objects; import java.util.SimpleTimeZone; import java.util.UUID; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; +import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; +import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo; import nodomain.freeyourgadget.gadgetbridge.devices.mijia_lywsd.AbstractMijiaLywsdCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.mijia_lywsd.MijiaLywsdHistoricSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.devices.mijia_lywsd.MijiaLywsdRealtimeSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.Device; +import nodomain.freeyourgadget.gadgetbridge.entities.MijiaLywsdHistoricSample; +import nodomain.freeyourgadget.gadgetbridge.entities.MijiaLywsdRealtimeSample; +import nodomain.freeyourgadget.gadgetbridge.entities.User; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.BatteryState; import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; import nodomain.freeyourgadget.gadgetbridge.service.btle.GattService; import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction; @@ -46,31 +57,37 @@ import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.deviceinfo.Dev import nodomain.freeyourgadget.gadgetbridge.util.GB; public class MijiaLywsdSupport extends AbstractBTLEDeviceSupport { - private static final Logger LOG = LoggerFactory.getLogger(MijiaLywsdSupport.class); + + private static final UUID UUID_BASE_SERVICE = UUID.fromString("ebe0ccb0-7a0a-4b0c-8a1a-6ff2997da3a6"); + private static final UUID UUID_TIME = UUID.fromString("ebe0ccb7-7a0a-4b0c-8a1a-6ff2997da3a6"); private static final UUID UUID_BATTERY = UUID.fromString("ebe0ccc4-7a0a-4b0c-8a1a-6ff2997da3a6"); private static final UUID UUID_SCALE = UUID.fromString("ebe0ccbe-7a0a-4b0c-8a1a-6ff2997da3a6"); private static final UUID UUID_CONN_INTERVAL = UUID.fromString("ebe0ccd8-7a0a-4b0c-8a1a-6ff2997da3a6"); + private static final UUID UUID_HISTORY = UUID.fromString("ebe0ccbc-7a0a-4b0c-8a1a-6ff2997da3a6"); + private static final UUID UUID_LIVE_DATA = UUID.fromString("ebe0ccc1-7a0a-4b0c-8a1a-6ff2997da3a6"); + private static final UUID UUID_HISTORY_LAST_ID = UUID.fromString("ebe0ccba-7a0a-4b0c-8a1a-6ff2997da3a6"); + private final DeviceInfoProfile deviceInfoProfile; private final GBDeviceEventVersionInfo versionCmd = new GBDeviceEventVersionInfo(); private final GBDeviceEventBatteryInfo batteryCmd = new GBDeviceEventBatteryInfo(); - private final IntentListener mListener = new IntentListener() { - @Override - public void notify(Intent intent) { - String s = intent.getAction(); - if (Objects.equals(s, DeviceInfoProfile.ACTION_DEVICE_INFO)) { - handleDeviceInfo((nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.deviceinfo.DeviceInfo) intent.getParcelableExtra(DeviceInfoProfile.EXTRA_DEVICE_INFO)); - } + private final IntentListener mListener = intent -> { + String s = intent.getAction(); + if (Objects.equals(s, DeviceInfoProfile.ACTION_DEVICE_INFO)) { + handleDeviceInfo((nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.deviceinfo.DeviceInfo) intent.getParcelableExtra(DeviceInfoProfile.EXTRA_DEVICE_INFO)); } }; + private int startupTime = 0; + public MijiaLywsdSupport() { super(LOG); addSupportedService(GattService.UUID_SERVICE_GENERIC_ACCESS); addSupportedService(GattService.UUID_SERVICE_GENERIC_ATTRIBUTE); addSupportedService(GattService.UUID_SERVICE_DEVICE_INFORMATION); - addSupportedService(UUID.fromString("ebe0ccb0-7a0a-4b0c-8a1a-6ff2997da3a6")); + addSupportedService(MijiaLywsdSupport.UUID_BASE_SERVICE); + deviceInfoProfile = new DeviceInfoProfile<>(this); deviceInfoProfile.addListener(mListener); addSupportedProfile(deviceInfoProfile); @@ -84,8 +101,15 @@ public class MijiaLywsdSupport extends AbstractBTLEDeviceSupport { final boolean supportsSetTime = getCoordinator().supportsSetTime(); if (supportsSetTime && GBApplication.getPrefs().getBoolean("datetime_synconconnect", true)) { setTime(builder); + } else { + getTime(builder); } + // TODO: We can't enable this without properly handling the historic data id and live data, otherwise + // it will cause battery drain on both the phone and device + //builder.notify(getCharacteristic(MijiaLywsdSupport.UUID_HISTORY), true); + //builder.notify(getCharacteristic(MijiaLywsdSupport.UUID_LIVE_DATA), true); + getBatteryInfo(builder); setConnectionInterval(builder); setInitialized(builder); @@ -119,17 +143,158 @@ public class MijiaLywsdSupport extends AbstractBTLEDeviceSupport { builder.read(batteryCharacteristc); } + private void getTime(TransactionBuilder builder) { + BluetoothGattCharacteristic timeCharacteristic = getCharacteristic(MijiaLywsdSupport.UUID_TIME); + builder.read(timeCharacteristic); + } + private void setTemperatureScale(TransactionBuilder builder, String scale) { BluetoothGattCharacteristic scaleCharacteristc = getCharacteristic(MijiaLywsdSupport.UUID_SCALE); builder.write(scaleCharacteristc, new byte[]{(byte) ("f".equals(scale) ? 0x01 : 0xff)}); } private void handleBatteryInfo(byte[] value, int status) { - if (status == BluetoothGatt.GATT_SUCCESS) { - batteryCmd.level = ((short) value[0]); - batteryCmd.state = (batteryCmd.level > 20) ? BatteryState.BATTERY_NORMAL : BatteryState.BATTERY_LOW; - handleGBDeviceEvent(batteryCmd); + if (status != BluetoothGatt.GATT_SUCCESS) { + LOG.warn("Unsuccessful response for handleBatteryInfo: {}", status); + return; } + + batteryCmd.level = ((short) value[0]); + batteryCmd.state = (batteryCmd.level > 20) ? BatteryState.BATTERY_NORMAL : BatteryState.BATTERY_LOW; + handleGBDeviceEvent(batteryCmd); + } + + private void handleHistory(final byte[] value, int status) { + if (status != BluetoothGatt.GATT_SUCCESS) { + LOG.warn("Unsuccessful response for handleHistory: {}", status); + return; + } + + if (value.length != 14) { + LOG.warn("Unexpected history length {}", value.length); + return; + } + + final ByteBuffer buf = ByteBuffer.wrap(value).order(ByteOrder.LITTLE_ENDIAN); + + final int id = buf.getInt(); + final int uptimeOffset = buf.getInt(); + final int maxTemperature = buf.getShort(); + final int maxHumidity = buf.get() & 0xff; + final int minTemperature = buf.getShort(); + final int minHumidity = buf.get() & 0xff; + + // Devices that do not support setting the time report the live data as an offset from the uptime + // other devices report the correct timestamp. + final int ts = (!getCoordinator().supportsSetTime() ? startupTime : 0) + uptimeOffset; + + LOG.info( + "Got history: id={}, uptimeOffset={}, ts={}, minTemperature={}, maxTemperature={}, minHumidity={}, maxHumidity={}", + id, + uptimeOffset, + ts, + minTemperature / 10.0f, + maxTemperature / 10.0f, + minHumidity, + maxHumidity + ); + + if (!getCoordinator().supportsSetTime() && startupTime <= 0) { + LOG.warn("Startup time is unknown - ignoring sample"); + return; + } + + try (DBHandler handler = GBApplication.acquireDB()) { + final DaoSession session = handler.getDaoSession(); + final GBDevice gbDevice = getDevice(); + final Device device = DBHelper.getDevice(gbDevice, session); + final User user = DBHelper.getUser(session); + + final MijiaLywsdHistoricSampleProvider sampleProvider = new MijiaLywsdHistoricSampleProvider(gbDevice, session); + + final MijiaLywsdHistoricSample sample = sampleProvider.createSample(); + sample.setTimestamp(ts * 1000L); + sample.setMinTemperature(minTemperature / 10.0f); + sample.setMaxTemperature(maxTemperature / 10.0f); + sample.setMinHumidity(minHumidity); + sample.setMaxHumidity(maxHumidity); + sample.setDevice(device); + sample.setUser(user); + + sampleProvider.addSample(sample); + } catch (final Exception e) { + GB.toast(getContext(), "Error saving historic sample", Toast.LENGTH_LONG, GB.ERROR); + LOG.error("Error saving historic samples", e); + } + } + + private void handleLiveData(final byte[] value, int status) { + if (status != BluetoothGatt.GATT_SUCCESS) { + LOG.warn("Unsuccessful response for handleLiveData: {}", status); + return; + } + + if (value.length != 5) { + LOG.warn("Unexpected live data length {}", value.length); + return; + } + + final ByteBuffer buf = ByteBuffer.wrap(value).order(ByteOrder.LITTLE_ENDIAN); + + final int temperature = buf.getShort(); + final int humidity = buf.get() & 0xff; + final int voltage = buf.getShort(); + + LOG.info( + "Got mijia live data: temperature={}, humidity={}, voltage={}", + temperature / 100f, + humidity, + voltage / 1000f + ); + + try (DBHandler handler = GBApplication.acquireDB()) { + final DaoSession session = handler.getDaoSession(); + final GBDevice gbDevice = getDevice(); + final Device device = DBHelper.getDevice(gbDevice, session); + final User user = DBHelper.getUser(session); + + final MijiaLywsdRealtimeSampleProvider sampleProvider = new MijiaLywsdRealtimeSampleProvider(gbDevice, session); + + final MijiaLywsdRealtimeSample sample = sampleProvider.createSample(); + sample.setTimestamp(System.currentTimeMillis()); + sample.setTemperature(temperature / 100.0f); + sample.setHumidity(humidity); + sample.setDevice(device); + sample.setUser(user); + + sampleProvider.addSample(sample); + } catch (final Exception e) { + GB.toast(getContext(), "Error saving historic sample", Toast.LENGTH_LONG, GB.ERROR); + LOG.error("Error saving historic samples", e); + } + + // Warning: this voltage value is not reliable, so I am not sure + // it's even worth usiong + //batteryCmd.voltage = voltage / 1000f; + //handleGBDeviceEvent(batteryCmd); + } + + private void handleTime(final byte[] value, int status) { + if (status != BluetoothGatt.GATT_SUCCESS) { + LOG.warn("Unsuccessful response for handleTime: {}", status); + return; + } + + if (value.length != 4) { + LOG.warn("Unexpected time length {}", value.length); + return; + } + + final int uptime = BLETypeConversions.toUint32(value); + + startupTime = (int) ((System.currentTimeMillis() / 1000L) - uptime); + + LOG.info("Got mijia time={}, startupTime={}", uptime, startupTime); } private void requestDeviceInfo(TransactionBuilder builder) { @@ -147,7 +312,7 @@ public class MijiaLywsdSupport extends AbstractBTLEDeviceSupport { } private void handleDeviceInfo(nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.deviceinfo.DeviceInfo info) { - LOG.warn("Device info: " + info); + LOG.info("Device info: {}", info); versionCmd.hwVersion = info.getHardwareRevision(); versionCmd.fwVersion = info.getFirmwareRevision(); handleGBDeviceEvent(versionCmd); @@ -155,6 +320,11 @@ public class MijiaLywsdSupport extends AbstractBTLEDeviceSupport { @Override public void onSetTime() { + if (!getCoordinator().supportsSetTime()) { + LOG.warn("setting time is not supported by this device"); + return; + } + TransactionBuilder builder; try { builder = performInitialized("Set time"); @@ -174,6 +344,18 @@ public class MijiaLywsdSupport extends AbstractBTLEDeviceSupport { } UUID characteristicUUID = characteristic.getUuid(); + + if (MijiaLywsdSupport.UUID_HISTORY.equals(characteristicUUID)) { + handleHistory(characteristic.getValue(), BluetoothGatt.GATT_SUCCESS); + return true; + } else if (MijiaLywsdSupport.UUID_LIVE_DATA.equals(characteristicUUID)) { + handleLiveData(characteristic.getValue(), BluetoothGatt.GATT_SUCCESS); + return true; + } else if (MijiaLywsdSupport.UUID_TIME.equals(characteristicUUID)) { + handleTime(characteristic.getValue(), BluetoothGatt.GATT_SUCCESS); + return true; + } + LOG.info("Unhandled characteristic changed: " + characteristicUUID); return false; } @@ -189,7 +371,17 @@ public class MijiaLywsdSupport extends AbstractBTLEDeviceSupport { if (MijiaLywsdSupport.UUID_BATTERY.equals(characteristicUUID)) { handleBatteryInfo(characteristic.getValue(), status); return true; + } else if (MijiaLywsdSupport.UUID_HISTORY.equals(characteristicUUID)) { + handleHistory(characteristic.getValue(), status); + return true; + } else if (MijiaLywsdSupport.UUID_LIVE_DATA.equals(characteristicUUID)) { + handleLiveData(characteristic.getValue(), status); + return true; + } else if (MijiaLywsdSupport.UUID_TIME.equals(characteristicUUID)) { + handleTime(characteristic.getValue(), status); + return true; } + LOG.info("Unhandled characteristic read: " + characteristicUUID); return false; }