From f68e4c865b09af96ed65ae75c96b549c091bfa8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Rebelo?= Date: Wed, 17 May 2023 21:54:29 +0100 Subject: [PATCH] Huami: Add stress, SpO2, heart rate fetch operations (no db persistence) Introduce a reusable abstract logic for repeated fetch operations. Add fetch operations for the following: - Stress (manual and automatic) - SpO2 (normal and sleep) - Heart rate (manual and resting) --- .../devices/huami/HuamiService.java | 7 +- .../operations/AbstractFetchOperation.java | 2 +- .../AbstractRepeatingFetchOperation.java | 209 ++++++++++++++++++ .../FetchManualHeartRateOperation.java | 68 ++++++ .../FetchRestingHeartRateOperation.java | 69 ++++++ .../operations/FetchSpo2NormalOperation.java | 77 +++++++ .../operations/FetchSpo2SleepOperation.java | 83 +++++++ .../operations/FetchStressAutoOperation.java | 66 ++++++ .../FetchStressManualOperation.java | 72 ++++++ .../HuamiFetchDebugLogsOperation.java | 1 - 10 files changed, 651 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/AbstractRepeatingFetchOperation.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchManualHeartRateOperation.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchRestingHeartRateOperation.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchSpo2NormalOperation.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchSpo2SleepOperation.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchStressAutoOperation.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchStressManualOperation.java diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiService.java index 32f2f6cca..6940c9a78 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiService.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiService.java @@ -222,8 +222,13 @@ public class HuamiService { public static final byte SUCCESS = 0x01; public static final byte COMMAND_ACTIVITY_DATA_START_DATE = 0x01; public static final byte COMMAND_ACTIVITY_DATA_TYPE_ACTIVTY = 0x01; - public static final byte COMMAND_ACTIVITY_DATA_TYPE_UNKNOWN_2 = 0x02; + public static final byte COMMAND_ACTIVITY_DATA_TYPE_MANUAL_HEART_RATE = 0x02; public static final byte COMMAND_ACTIVITY_DATA_XXX_DATE = 0x02; // issued on first connect, followd by COMMAND_XXXX_ACTIVITY_DATA instead of COMMAND_FETCH_DATA + public static final byte COMMAND_ACTIVITY_DATA_TYPE_STRESS_MANUAL = 0x12; + public static final byte COMMAND_ACTIVITY_DATA_TYPE_STRESS_AUTOMATIC = 0x13; + public static final byte COMMAND_ACTIVITY_DATA_TYPE_SPO2_NORMAL = 0x25; + public static final byte COMMAND_ACTIVITY_DATA_TYPE_SPO2_SLEEP = 0x26; + public static final byte COMMAND_ACTIVITY_DATA_TYPE_RESTING_HEART_RATE = 0x3a; public static final byte COMMAND_FIRMWARE_INIT = 0x01; // to UUID_CHARACTERISTIC_FIRMWARE, followed by fw file size in bytes public static final byte COMMAND_FIRMWARE_START_DATA = 0x03; // to UUID_CHARACTERISTIC_FIRMWARE diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/AbstractFetchOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/AbstractFetchOperation.java index 9a5da4c22..a6b899848 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/AbstractFetchOperation.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/AbstractFetchOperation.java @@ -297,7 +297,7 @@ public abstract class AbstractFetchOperation extends AbstractHuamiOperation { sendAck2021(keepActivityDataOnDevice || !handleFinishSuccess); } - private void sendAck2021(final boolean keepDataOnDevice) { + protected void sendAck2021(final boolean keepDataOnDevice) { if (!(getSupport() instanceof Huami2021Support)) { return; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/AbstractRepeatingFetchOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/AbstractRepeatingFetchOperation.java new file mode 100644 index 000000000..b87415bb2 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/AbstractRepeatingFetchOperation.java @@ -0,0 +1,209 @@ +/* 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.service.devices.huami.operations; + +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCharacteristic; +import android.widget.Toast; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.GregorianCalendar; +import java.util.Locale; + +import nodomain.freeyourgadget.gadgetbridge.Logging; +import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator; +import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSupport; +import nodomain.freeyourgadget.gadgetbridge.util.CheckSums; +import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; +import nodomain.freeyourgadget.gadgetbridge.util.GB; + +/** + * A repeating fetch operation. This operation repeats the fetch up to a certain number of times, or + * until the fetch timestamp matches the current time. For every fetch, a new operation must + * be created, i.e. an operation may not be reused for multiple fetches. + */ +public abstract class AbstractRepeatingFetchOperation extends AbstractFetchOperation { + private static final Logger LOG = LoggerFactory.getLogger(AbstractRepeatingFetchOperation.class); + + private final ByteArrayOutputStream byteStreamBuffer = new ByteArrayOutputStream(140); + + protected final byte dataType; + + public AbstractRepeatingFetchOperation(final HuamiSupport support, final byte dataType, final String dataName) { + super(support); + this.dataType = dataType; + setName("fetching " + dataName); + } + + @Override + protected void startFetching(final TransactionBuilder builder) { + LOG.info("start {}", getName()); + final GregorianCalendar sinceWhen = getLastSuccessfulSyncTime(); + startFetching(builder, dataType, sinceWhen); + } + + /** + * Handle the buffered activity data. + * + * @param timestamp The timestamp of the first sample. This function should update this to the + * timestamp of the last processed sample. + * @param bytes the buffered bytes + * @return true on success + */ + protected abstract boolean handleActivityData(final GregorianCalendar timestamp, final byte[] bytes); + + @Override + protected boolean handleActivityFetchFinish(final boolean success) { + LOG.info("{} has finished round {}: {}, got {} bytes in buffer", getName(), fetchCount, success, byteStreamBuffer.size()); + + if (!success) { + super.handleActivityFetchFinish(false); + return false; + } + + if (byteStreamBuffer.size() == 0) { + return super.handleActivityFetchFinish(true); + } + + final byte[] bytes = byteStreamBuffer.toByteArray(); + final GregorianCalendar timestamp = (GregorianCalendar) this.startTimestamp.clone(); + + // Uncomment to dump the bytes to external storage for debugging + //dumpBytesToExternalStorage(bytes, timestamp); + + final boolean handleSuccess = handleActivityData(timestamp, bytes); + + if (!handleSuccess) { + super.handleActivityFetchFinish(false); + return false; + } + + timestamp.add(Calendar.MINUTE, 1); + saveLastSyncTimestamp(timestamp); + + if (needsAnotherFetch(timestamp)) { + byteStreamBuffer.reset(); + + try { + final boolean keepActivityDataOnDevice = HuamiCoordinator.getKeepActivityDataOnDevice(getDevice().getAddress()); + sendAck2021(keepActivityDataOnDevice); + startFetching(); + return true; + } catch (final IOException ex) { + LOG.error("Error starting another round of " + getName(), ex); + super.handleActivityFetchFinish(false); + return false; + } + } + + final boolean superSuccess = super.handleActivityFetchFinish(true); + postActivityFetchFinish(superSuccess); + return superSuccess; + } + + protected void postActivityFetchFinish(final boolean success) { + + } + + @Override + protected boolean validChecksum(final int crc32) { + return crc32 == CheckSums.getCRC32(byteStreamBuffer.toByteArray()); + } + + @Override + public boolean onCharacteristicRead(final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic, final int status) { + LOG.debug("characteristic read: {}: {}", characteristic.getUuid(), Logging.formatBytes(characteristic.getValue())); + return super.onCharacteristicRead(gatt, characteristic, status); + } + + @Override + protected void handleActivityNotif(final byte[] value) { + LOG.debug("{} data: {}", getName(), Logging.formatBytes(value)); + + if (!isOperationRunning()) { + LOG.error("ignoring {} notification because operation is not running. Data length: {}", getName(), value.length); + getSupport().logMessageContent(value); + return; + } + + if ((byte) (lastPacketCounter + 1) == value[0]) { + // TODO we should handle skipped or repeated bytes more gracefully + lastPacketCounter++; + bufferActivityData(value); + } else { + GB.toast("Error " + getName() + ", invalid package counter: " + value[0] + ", last was: " + lastPacketCounter, Toast.LENGTH_LONG, GB.ERROR); + handleActivityFetchFinish(false); + } + } + + @Override + protected void bufferActivityData(final byte[] value) { + byteStreamBuffer.write(value, 1, value.length - 1); // skip the counter + } + + private boolean needsAnotherFetch(final GregorianCalendar lastSyncTimestamp) { + final long lastFetchRange = lastSyncTimestamp.getTimeInMillis() - startTimestamp.getTimeInMillis(); + if (lastFetchRange < 1000L) { + LOG.warn("Fetch round {} of {} got {} ms of data, stopping to avoid infinite loop", fetchCount, getName(), lastFetchRange); + return false; + } + + if (fetchCount > 5) { + LOG.warn("Already have {} fetch rounds for {}, not doing another one", fetchCount, getName()); + return false; + } + + if (lastSyncTimestamp.getTimeInMillis() >= System.currentTimeMillis()) { + LOG.warn("Not doing another fetch since last synced timestamp is in the future: {}", lastSyncTimestamp.getTime()); + return false; + } + + LOG.info("Doing another fetch since last sync timestamp is still too old: {}", lastSyncTimestamp.getTime()); + return true; + } + + public void dumpBytesToExternalStorage(final byte[] bytes, final GregorianCalendar timestamp) { + try { + final File externalFilesDir = FileUtils.getExternalFilesDir(); + final File targetDir = new File(externalFilesDir, "rawFetchOperations"); + targetDir.mkdirs(); + + final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMddHHmmssSSS", Locale.US); + final String filename = getClass().getSimpleName() + "_" + + timestamp.getTime().getTime() + "_" + + dateFormat.format(timestamp.getTime()) + ".bin"; + + final File outputFile = new File(targetDir, filename); + + final OutputStream outputStream = new FileOutputStream(outputFile); + outputStream.write(bytes); + outputStream.close(); + } catch (final Exception e) { + LOG.error("Failed to dump bytes to storage", e); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchManualHeartRateOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchManualHeartRateOperation.java new file mode 100644 index 000000000..c51cdaf80 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchManualHeartRateOperation.java @@ -0,0 +1,68 @@ +/* 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.service.devices.huami.operations; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.GregorianCalendar; + +import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService; +import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSupport; + +/** + * An operation that fetches manual HR measurement data. + */ +public class FetchManualHeartRateOperation extends AbstractRepeatingFetchOperation { + private static final Logger LOG = LoggerFactory.getLogger(FetchManualHeartRateOperation.class); + + public FetchManualHeartRateOperation(final HuamiSupport support) { + super(support, HuamiService.COMMAND_ACTIVITY_DATA_TYPE_MANUAL_HEART_RATE, "manual hr data"); + } + + @Override + protected boolean handleActivityData(final GregorianCalendar timestamp, final byte[] bytes) { + if (bytes.length % 6 != 0) { + LOG.warn("Unexpected buffered manual heart rate data size {} is not a multiple of 6", bytes.length); + return false; + } + + final ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); + + while (buffer.position() < bytes.length) { + final long currentTimestamp = BLETypeConversions.toUnsigned(buffer.getInt()) * 1000; + timestamp.setTimeInMillis(currentTimestamp); + + final byte unknown1 = buffer.get(); // always 4? + final int hr = buffer.get() & 0xff; + + LOG.info("Manual HR at {}: {}", timestamp.getTime(), hr); + + // TODO: Save manual hr data + } + + return true; + } + + @Override + protected String getLastSyncTimeKey() { + return "lastManualHeartRateTimeMillis"; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchRestingHeartRateOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchRestingHeartRateOperation.java new file mode 100644 index 000000000..419ca8e4a --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchRestingHeartRateOperation.java @@ -0,0 +1,69 @@ +/* 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.service.devices.huami.operations; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.GregorianCalendar; + +import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService; +import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSupport; + +/** + * An operation that fetches resting heart rate data. + */ +public class FetchRestingHeartRateOperation extends AbstractRepeatingFetchOperation { + private static final Logger LOG = LoggerFactory.getLogger(FetchRestingHeartRateOperation.class); + + public FetchRestingHeartRateOperation(final HuamiSupport support) { + super(support, HuamiService.COMMAND_ACTIVITY_DATA_TYPE_RESTING_HEART_RATE, "resting hr data"); + } + + @Override + protected boolean handleActivityData(final GregorianCalendar timestamp, final byte[] bytes) { + if (bytes.length % 6 != 0) { + LOG.warn("Unexpected buffered rest heart rate data size {} is not a multiple of 6", bytes.length); + return false; + } + + final ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); + + while (buffer.position() < bytes.length) { + final long currentTimestamp = BLETypeConversions.toUnsigned(buffer.getInt()) * 1000; + timestamp.setTimeInMillis(currentTimestamp); + // Official app only shows this at day-level + + final byte unknown1 = buffer.get(); // always 4? + final int hr = buffer.get() & 0xff; + + LOG.debug("Resting HR at {}: {}", timestamp.getTime(), hr); + + // TODO: Save resting hr data + } + + return true; + } + + @Override + protected String getLastSyncTimeKey() { + return "lastRestingHeartRateTimeMillis"; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchSpo2NormalOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchSpo2NormalOperation.java new file mode 100644 index 000000000..a4dc78dc7 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchSpo2NormalOperation.java @@ -0,0 +1,77 @@ +/* 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.service.devices.huami.operations; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.GregorianCalendar; + +import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSupport; +import nodomain.freeyourgadget.gadgetbridge.util.GB; + +/** + * An operation that fetches SPO2 data for normal (manual and automatic) measurements. + */ +public class FetchSpo2NormalOperation extends AbstractRepeatingFetchOperation { + private static final Logger LOG = LoggerFactory.getLogger(FetchSpo2NormalOperation.class); + + public FetchSpo2NormalOperation(final HuamiSupport support) { + super(support, HuamiService.COMMAND_ACTIVITY_DATA_TYPE_SPO2_NORMAL, "spo2 normal data"); + } + + @Override + protected boolean handleActivityData(final GregorianCalendar timestamp, final byte[] bytes) { + if ((bytes.length - 1) % 65 != 0) { + LOG.error("Unexpected length for spo2 data {}, not divisible by 65", bytes.length); + return false; + } + + final ByteBuffer buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); + + final int version = buf.get() & 0xff; + if (version != 2) { + LOG.error("Unknown normal spo2 data version {}", version); + return false; + } + + while (buf.position() < bytes.length) { + final long timestampSeconds = buf.getInt(); + final byte spo2raw = buf.get(); + final boolean autoMeasurement = (spo2raw < 0); + final byte spo2 = (byte) (autoMeasurement ? (spo2raw + 128) : spo2raw); + + final byte[] unknown = new byte[60]; // starts with a few spo2 values, but mostly zeroes after? + buf.get(unknown); + + timestamp.setTimeInMillis(timestampSeconds * 1000L); + + LOG.info("SPO2 at {}: {} auto={} unknown={}", timestamp.getTime(), spo2, autoMeasurement, GB.hexdump(unknown)); + // TODO save + } + + return true; + } + + @Override + protected String getLastSyncTimeKey() { + return "lastSpo2normalTimeMillis"; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchSpo2SleepOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchSpo2SleepOperation.java new file mode 100644 index 000000000..af0970465 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchSpo2SleepOperation.java @@ -0,0 +1,83 @@ +/* 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.service.devices.huami.operations; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.GregorianCalendar; + +import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSupport; +import nodomain.freeyourgadget.gadgetbridge.util.GB; + +/** + * An operation that fetches SPO2 data for sleep measurements (this requires sleep breathing quality enabled). + */ +public class FetchSpo2SleepOperation extends AbstractRepeatingFetchOperation { + private static final Logger LOG = LoggerFactory.getLogger(FetchSpo2SleepOperation.class); + + public FetchSpo2SleepOperation(final HuamiSupport support) { + super(support, HuamiService.COMMAND_ACTIVITY_DATA_TYPE_SPO2_SLEEP, "spo2 sleep data"); + } + + @Override + protected boolean handleActivityData(final GregorianCalendar timestamp, final byte[] bytes) { + if ((bytes.length - 1) % 30 != 0) { + LOG.error("Unexpected length for sleep spo2 data {}, not divisible by 30", bytes.length); + return false; + } + + final ByteBuffer buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); + + final int version = buf.get() & 0xff; + if (version != 2) { + LOG.error("Unknown sleep spo2 data version {}", version); + return false; + } + + while (buf.position() < bytes.length) { + final long timestampSeconds = buf.getInt(); + // this doesn't match the spo2 value returned by FetchSpo2NormalOperation.. it's often 100 when the other is 99, but not always + final int spo2 = buf.get() & 0xff; + + // Not sure what the nextg 25 bytes mean: + // 40646464646464636363636363000000000000400000000000 + // an unknown byte, always 0x40 (64) + // 6 bytes with max values? + // 6 bytes with min values? + // 12 unknown bytes, always ending with 4 zeroes? + final byte[] unknown = new byte[25]; + + buf.get(unknown); + + timestamp.setTimeInMillis(timestampSeconds * 1000L); + + LOG.info("SPO2 (sleep) at {}: {} unknown={}", timestamp.getTime(), spo2, GB.hexdump(unknown)); + // TODO save + } + + return true; + } + + @Override + protected String getLastSyncTimeKey() { + return "lastSpo2sleepTimeMillis"; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchStressAutoOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchStressAutoOperation.java new file mode 100644 index 000000000..bf025bd7c --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchStressAutoOperation.java @@ -0,0 +1,66 @@ +/* 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.service.devices.huami.operations; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Calendar; +import java.util.GregorianCalendar; + +import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSupport; + +/** + * An operation that fetches auto stress data. + */ +public class FetchStressAutoOperation extends AbstractRepeatingFetchOperation { + private static final Logger LOG = LoggerFactory.getLogger(FetchStressAutoOperation.class); + + public FetchStressAutoOperation(final HuamiSupport support) { + super(support, HuamiService.COMMAND_ACTIVITY_DATA_TYPE_STRESS_AUTOMATIC, "auto stress data"); + } + + @Override + protected boolean handleActivityData(final GregorianCalendar timestamp, final byte[] bytes) { + for (byte b : bytes) { + timestamp.add(Calendar.MINUTE, 1); + + if (b == -1) { + continue; + } + + final int stress = b & 0xff; + + // 0-39 = relaxed + // 40-59 = mild + // 60-79 = moderate + // 80-100 = high + + LOG.info("Stress (auto) at {}: {}", timestamp.getTime(), stress); + + // TODO: Save stress data + } + + return true; + } + + @Override + protected String getLastSyncTimeKey() { + return "lastStressAutoTimeMillis"; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchStressManualOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchStressManualOperation.java new file mode 100644 index 000000000..e6c276a29 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchStressManualOperation.java @@ -0,0 +1,72 @@ +/* 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.service.devices.huami.operations; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.GregorianCalendar; + +import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService; +import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSupport; + +/** + * An operation that fetches manual stress data. + */ +public class FetchStressManualOperation extends AbstractRepeatingFetchOperation { + private static final Logger LOG = LoggerFactory.getLogger(FetchStressManualOperation.class); + + public FetchStressManualOperation(final HuamiSupport support) { + super(support, HuamiService.COMMAND_ACTIVITY_DATA_TYPE_STRESS_MANUAL, "manual stress data"); + } + + @Override + protected boolean handleActivityData(final GregorianCalendar timestamp, final byte[] bytes) { + if (bytes.length % 5 != 0) { + LOG.info("Unexpected buffered stress data size {} is not a multiple of 5", bytes.length); + return false; + } + + final ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); + final GregorianCalendar lastSyncTimestamp = new GregorianCalendar(); + + while (buffer.position() < bytes.length) { + final long currentTimestamp = BLETypeConversions.toUnsigned(buffer.getInt()) * 1000; + + // 0-39 = relaxed + // 40-59 = mild + // 60-79 = moderate + // 80-100 = high + final int stress = buffer.get() & 0xff; + timestamp.setTimeInMillis(currentTimestamp); + + LOG.info("Stress (manual) at {}: {}", lastSyncTimestamp.getTime(), stress); + + // TODO: Save stress data + } + + return true; + } + + @Override + protected String getLastSyncTimeKey() { + return "lastStressManualTimeMillis"; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/HuamiFetchDebugLogsOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/HuamiFetchDebugLogsOperation.java index edbf915c4..ba6797eba 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/HuamiFetchDebugLogsOperation.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/HuamiFetchDebugLogsOperation.java @@ -36,7 +36,6 @@ import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbip.AmazfitBipS import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSupport; -import nodomain.freeyourgadget.gadgetbridge.util.CheckSums; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; import nodomain.freeyourgadget.gadgetbridge.util.GB;