/* Copyright (C) 2023-2024 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.xiaomi.activity.impl; import android.widget.Toast; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.nio.BufferUnderflowException; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.ArrayList; import java.util.List; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; import nodomain.freeyourgadget.gadgetbridge.devices.HeartPulseSampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiSleepStageSampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.XiaomiSleepTimeSampleProvider; import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; import nodomain.freeyourgadget.gadgetbridge.entities.Device; import nodomain.freeyourgadget.gadgetbridge.entities.HeartPulseSample; import nodomain.freeyourgadget.gadgetbridge.entities.User; import nodomain.freeyourgadget.gadgetbridge.entities.XiaomiSleepStageSample; import nodomain.freeyourgadget.gadgetbridge.entities.XiaomiSleepTimeSample; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.XiaomiSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.XiaomiActivityFileId; import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.activity.XiaomiActivityParser; import nodomain.freeyourgadget.gadgetbridge.util.GB; public class SleepDetailsParser extends XiaomiActivityParser { private static final Logger LOG = LoggerFactory.getLogger(SleepDetailsParser.class); @Override public boolean parse(final XiaomiSupport support, final XiaomiActivityFileId fileId, final byte[] bytes) { // Seems to come both as DetailType.DETAILS (version 2) and DetailType.SUMMARY (version 4) if (fileId.getVersion() > 4) { LOG.warn("Unknown sleep details version {}", fileId.getVersion()); return false; } // Stores number of fields which are only present in certain versions of the message // this is required for correct header offset calculation int versionDependentFields = 0; final ByteBuffer buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); buf.get(new byte[7]); // skip fileId bytes final byte fileIdPadding = buf.get(); if (fileIdPadding != 0) { LOG.warn("Expected 0 padding after fileId, got {} - parsing might fail", fileIdPadding); } final byte header = buf.get(); final int isAwake = buf.get() & 0xff; // 0/1 - more correctly this would be !isSleepFinish final int bedTime = buf.getInt(); final int wakeupTime = buf.getInt(); int sleepQuality = -1; if (fileId.getVersion() >= 4) { versionDependentFields += 1; sleepQuality = buf.get() & 0xff; } // note on RR-intervals (msg type 1) on Xiaomi band 9, FW 2.3.93, HW M2345B1: // // the band collects RR interval in groups of ~10 minutes, which are then sent as msg type 1 // along with an associated RTC timestamp. RTC timestamp jitter and drift of up to // +/-several seconds was observed, compared to the sum of intervals in a given msg. // // In order to preserve RR-interval continuity throughout the entire duration of sleep, as // is necessary for breath-detection algorithm, and to avoid negative or impossibly large // (false) RR intervals, we maintain an running timestamp of last heart pulse. // While processing msgs, in case the detected jitter is too large, the running timestamp // is reset to the current msg timestamp. long lastHeartPulseTimestamp = 0; // tracks the true timestamp of last heart pulse across the jittery 10-minute segments LOG.debug("Sleep sample: bedTime: {}, wakeupTime: {}, isAwake: {}", bedTime, wakeupTime, isAwake); final List summaries = new ArrayList<>(); XiaomiSleepTimeSample sample = new XiaomiSleepTimeSample(); sample.setTimestamp(bedTime * 1000L); sample.setWakeupTime(wakeupTime * 1000L); sample.setIsAwake(isAwake == 1); // Heart rate samples if ((header & (1 << (5 - versionDependentFields))) != 0) { LOG.debug("Heart rate samples from offset {}", Integer.toHexString(buf.position())); final int unit = buf.getShort(); // Time unit (i.e sample rate) final int count = buf.getShort(); if (count > 0) { // If version is less than 2 firstRecordTime is bedTime if (fileId.getVersion() >= 2) { final int firstRecordTime = buf.getInt(); } // Skip count samples - each sample is a u8 // timestamp of each sample is firstRecordTime + (unit * index) buf.position(buf.position() + count); } } // SpO2 samples if ((header & (1 << (4 - versionDependentFields))) != 0) { LOG.debug("SpO₂ samples from offset {}", Integer.toHexString(buf.position())); final int unit = buf.getShort(); // Time unit (i.e sample rate) final int count = buf.getShort(); if (count > 0) { // If version is less than 2 firstRecordTime is bedTime if (fileId.getVersion() >= 2) { final int firstRecordTime = buf.getInt(); } // Skip count samples - each sample is a u8 // timestamp of each sample is firstRecordTime + (unit * index) buf.position(buf.position() + count); } } // snore samples if (fileId.getVersion() >= 3 && (header & (1 << (3 - versionDependentFields))) != 0) { LOG.debug("Snore level samples from offset {}", Integer.toHexString(buf.position())); final int unit = buf.getShort(); // Time unit (i.e sample rate) final int count = buf.getShort(); if (count > 0) { // If version is less than 2 firstRecordTime is bedTime if (fileId.getVersion() >= 2) { final int firstRecordTime = buf.getInt(); } // Skip count samples - each sample is a float // timestamp of each sample is firstRecordTime + (unit * index) buf.position(buf.position() + count * 4); } } final List stages = new ArrayList<>(); final List heartPulseSamples = new ArrayList<>(); LOG.debug("Sleep stage packets from offset {}", Integer.toHexString(buf.position())); // Do not crash if we face a buffer underflow, as the next parsing is not 100% fool-proof, // and we still want to persist whatever we got so far boolean stagesParseFailed = false; try { while (buf.remaining() >= 17) { if (!readStagePacketHeader(buf)) { break; } final int headerLen = buf.get() & 0xFF; // this seems to always be 17 // This timestamp is kind of weird, is seems to sometimes be in seconds // and other times in nanoseconds. Message types 16 and 17 are in seconds final long ts = buf.getLong(); final int parity = buf.get() & 0xFF; // sum of stage bit count should be uneven final int type = buf.get() & 0xFF; final int dataLen = ((buf.get() & 0xFF) << 8) | (buf.get() & 0xFF); // Known types: // - acc_unk = 0, // - ppg_unk = 1, // - fall_asleep = 2, // - wake_up = 3, // - switch_ts_unk1 = 12, // - switch_ts_unk2 = 13, // - Summary = 16, // - Stages = 17 if (type == 0x2 || type == 0x3 || type == 0x9 || type == 0xc || type == 0xd || type == 0xe || type == 0xf) { // the bytes reserved for the data length are believed to be flags, as they // do not actually have any data following the headers continue; } final byte[] data = new byte[dataLen]; buf.get(data); final ByteBuffer dataBuf = ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN); if (type == 1) { // RR intervals: https://en.wikipedia.org/wiki/RR_interval // add first heartbeat before intervals, if necessary if (Math.abs(lastHeartPulseTimestamp - ts) > 30000) { // 30 seconds // we drifted too far from RTC timestamp, or this is the very first sample: lastHeartPulseTimestamp = ts; // resync final HeartPulseSample heartPulseSample = new HeartPulseSample(); heartPulseSample.setTimestamp(lastHeartPulseTimestamp); heartPulseSamples.add(heartPulseSample); } // add heartbeats after intervals while (dataBuf.position() < dataBuf.limit()) { final int delta = dataBuf.get() & 0xff; // delta is in 10msec units lastHeartPulseTimestamp += 10 * delta; // convert to 1msec units for timestamps final HeartPulseSample heartPulseSample = new HeartPulseSample(); heartPulseSample.setTimestamp(lastHeartPulseTimestamp); heartPulseSamples.add(heartPulseSample); } } else if (type == 16) { final int data_0 = dataBuf.get() & 0xFF; final int sleep_index = data_0 >> 4; final int wake_count = data_0 & 0x0F; final int sleep_duration = dataBuf.getShort() & 0xFFFF; final int wake_duration = dataBuf.getShort() & 0xFFFF; final int light_duration = dataBuf.getShort() & 0xFFFF; final int rem_duration = dataBuf.getShort() & 0xFFFF; final int deep_duration = dataBuf.getShort() & 0xFFFF; final int data_1 = dataBuf.get() & 0xFF; final boolean has_rem = (data_1 >> 4) == 1; final boolean has_stage = (data_1 >> 2) == 1; // Could probably be an "awake" duration after sleep final int unk_duration_minutes = dataBuf.get() & 0xFF; if (sample == null) { sample = new XiaomiSleepTimeSample(); } sample.setTimestamp(bedTime * 1000L); sample.setWakeupTime(wakeupTime * 1000L); sample.setTotalDuration(sleep_duration); sample.setDeepSleepDuration(deep_duration); sample.setLightSleepDuration(light_duration); sample.setRemSleepDuration(rem_duration); sample.setAwakeDuration(wake_duration); // FIXME: This is an array, but we end up persisting only the last sample, since // the timestamp is the primary key summaries.add(sample); sample = null; } else if (type == 17) { // Stages long currentTime = ts * 1000; for (int i = 0; i < dataLen / 2; i++) { // when the change to the phase occurs final int val = dataBuf.getShort() & 0xFFFF; final int stage = val >> 12; final int offsetMinutes = val & 0xFFF; final XiaomiSleepStageSample stageSample = new XiaomiSleepStageSample(); stageSample.setTimestamp(currentTime); stageSample.setStage(decodeStage(stage)); stages.add(stageSample); currentTime += offsetMinutes * 60000; } } } } catch (final BufferUnderflowException e) { LOG.warn("Buffer underflow while parsing sleep stages...", e); stagesParseFailed = true; } if (summaries.isEmpty()) { // We did not manage to find sleep stage samples - ensure we at least persist the base one summaries.add(sample); } boolean persistSuccess = !stagesParseFailed; // save all the samples that we got try (DBHandler handler = GBApplication.acquireDB()) { final DaoSession session = handler.getDaoSession(); final GBDevice gbDevice = support.getDevice(); final XiaomiSleepTimeSampleProvider sampleProvider = new XiaomiSleepTimeSampleProvider(gbDevice, session); for (final XiaomiSleepTimeSample summary : summaries) { summary.setDevice(DBHelper.getDevice(gbDevice, session)); summary.setUser(DBHelper.getUser(session)); // Check if there is already a later sleep sample - if so, ignore this one // Samples for the same sleep will always have the same bedtime (timestamp), but we might get // multiple bedtimes until the user wakes up final List existingSamples = sampleProvider.getAllSamples(summary.getTimestamp(), summary.getTimestamp()); if (!existingSamples.isEmpty()) { final XiaomiSleepTimeSample existingSample = existingSamples.get(0); if (existingSample.getWakeupTime() > summary.getWakeupTime()) { LOG.warn("Ignoring sleep sample - existing sample is more recent ({})", existingSample.getWakeupTime()); continue; } } sampleProvider.addSample(summary); } } catch (final Exception e) { GB.toast(support.getContext(), "Error saving sleep sample", Toast.LENGTH_LONG, GB.ERROR); LOG.error("Error saving sleep sample", e); persistSuccess = false; } if (!stagesParseFailed && !stages.isEmpty()) { LOG.debug("Persisting {} sleep stage samples", stages.size()); // Save the sleep stage samples try (DBHandler handler = GBApplication.acquireDB()) { final DaoSession session = handler.getDaoSession(); final GBDevice gbDevice = support.getDevice(); final Device device = DBHelper.getDevice(gbDevice, session); final User user = DBHelper.getUser(session); final XiaomiSleepStageSampleProvider sampleProvider = new XiaomiSleepStageSampleProvider(gbDevice, session); for (final XiaomiSleepStageSample stageSample : stages) { stageSample.setDevice(device); stageSample.setUser(user); } sampleProvider.addSamples(stages); } catch (final Exception e) { GB.toast(support.getContext(), "Error saving sleep stage samples", Toast.LENGTH_LONG, GB.ERROR); LOG.error("Error saving sleep stage samples", e); persistSuccess = false; } } // Save the heart pulse samples try (DBHandler handler = GBApplication.acquireDB()) { final DaoSession session = handler.getDaoSession(); final GBDevice gbDevice = support.getDevice(); final Device device = DBHelper.getDevice(gbDevice, session); final User user = DBHelper.getUser(session); final HeartPulseSampleProvider sampleProvider = new HeartPulseSampleProvider(gbDevice, session); for (final HeartPulseSample stageSample : heartPulseSamples) { stageSample.setDevice(device); stageSample.setUser(user); } sampleProvider.addSamples(heartPulseSamples); } catch (final Exception e) { GB.toast(support.getContext(), "Error saving heart pulse samples", Toast.LENGTH_LONG, GB.ERROR); LOG.error("Error saving heart pulse samples", e); persistSuccess = false; } return persistSuccess; } private static boolean readStagePacketHeader(final ByteBuffer buffer) { while (buffer.remaining() >= 17) { if (buffer.getInt() != 0xfffcfafb) { // rollback to second byte of header buffer.position(buffer.position() - 3); continue; } return true; } return false; } private static int decodeStage(int rawStage) { switch (rawStage) { case 0: return 5; // AWAKE case 1: return 3; // LIGHT_SLEEP case 2: return 2; // DEEP_SLEEP case 3: return 4; // REM_SLEEP case 4: return 0; // NOT_SLEEP default: return 1; // N/A } } }