/* Copyright (C) 2024 Martin.JM 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.huawei; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.ArrayList; import java.util.Arrays; import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.HuaweiFileDownloadManager; import nodomain.freeyourgadget.gadgetbridge.service.devices.huawei.HuaweiSupportProvider; import nodomain.freeyourgadget.gadgetbridge.util.GB; public class HuaweiTruSleepParser { private static final Logger LOG = LoggerFactory.getLogger(HuaweiTruSleepParser.class); public static final int TAG_ACC = 1; public static final int TAG_PPG = 2; public static class TruSleepStatus { public final int startTime; public final int endTime; public TruSleepStatus(int startTime, int endTime) { this.startTime = startTime; this.endTime = endTime; } @Override public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) return false; TruSleepStatus that = (TruSleepStatus) o; return startTime == that.startTime && endTime == that.endTime; } @Override public String toString() { return "TruSleepStatus{" + "endTime=" + endTime + ", startTime=" + startTime + '}'; } } public static class TruSleepDataAcc { public final int startTime; public final short flags; /* Accelerometer (ACC): Accelerometer data is sampled every 60 seconds, 24h a day and provides activity data on 3 axis. Each axis activity is represented as byte, where 0x00 is no activity and 0xff is high activity. The 3rd axis is ignored as it's always high (due to gravity??). */ public TruSleepDataAcc(int startTime, short flags) { this.startTime = startTime; this.flags = flags; } @Override public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) return false; TruSleepDataAcc that = (TruSleepDataAcc) o; if (startTime != that.startTime) return false; return flags == that.flags; } @Override public String toString() { return "TruSleepDataAcc{" + "startTime=" + startTime + ", flags=" + flags + '}'; } } /* Photoplethysmogram (PPG): Every sample has a millisecond timestamp. At night data is sampled every second, but at day it's sampled less often (about every 5 minutes). */ public static class TruSleepDataPpg { public final long startTime; public final short flags; TruSleepDataPpg(long startTime, short flags) { this.startTime = startTime; this.flags = flags; } @Override public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) return false; TruSleepDataPpg that = (TruSleepDataPpg) o; if (startTime != that.startTime) return false; return flags == that.flags; } @Override public String toString() { return "TruSleepDataPpg{" + "startTime=" + startTime + ", flags=" + flags + '}'; } } public static class TruSleepData { public ArrayList dataACCs; public ArrayList dataPPGs; public TruSleepData() { this.dataACCs = new ArrayList<>(); this.dataPPGs = new ArrayList<>(); } @Override public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) return false; TruSleepData that = (TruSleepData) o; if (!dataACCs.equals(that.dataACCs)) return false; return dataPPGs.equals(that.dataPPGs); } @Override public String toString() { return "TruSleepData{" + "dataPPGs=" + dataPPGs.toString() + ", dataACCs=" + dataACCs.toString() + '}'; } private static final byte TAG_COMPRESSION_RAW = (byte)0xaa; private static final byte TAG_COMPRESSION_COMP = (byte)0xbb; private static final byte TAG_COMPRESSION_RESTART = (byte)0xff; public void decodeAcc(byte[] data) throws IllegalArgumentException { /* Format: - Timestamp (4 byte) - Flags (3 bytes) */ ByteBuffer buffer = ByteBuffer.wrap(data); buffer.order(ByteOrder.LITTLE_ENDIAN); if (buffer.remaining() < 4) throw new IllegalArgumentException("Timestamp is missing"); int startTime = buffer.getInt(); if (buffer.remaining() < 2) throw new IllegalArgumentException("Flags are missing"); short flags = buffer.getShort(); dataACCs.add(new TruSleepDataAcc(startTime, flags)); } public void decodePpg(byte[] data) throws IllegalArgumentException, IllegalStateException { /* Format: - Timestamp (4 byte) - Number of UINT16 (1 byte) - Compression tag (1 byte) - Compressed data - Compression tag (1 byte) - Compressed data */ ByteBuffer buffer = ByteBuffer.wrap(data); buffer.order(ByteOrder.LITTLE_ENDIAN); if (buffer.remaining() < 4) throw new IllegalArgumentException("Timestamp is missing"); int startTime = buffer.getInt(); if (buffer.remaining() < 1) throw new IllegalArgumentException("Count of short is missing"); byte countShort = buffer.get(); ArrayList peak = decodePeak(countShort, buffer); ArrayList amp = decodeAmp(countShort, buffer); // Sanity check if (peak.size() != countShort || amp.size() != countShort) throw new IllegalStateException("Decoded arrays have different length"); for (int i = 0; i < countShort; i++) { dataPPGs.add(new TruSleepDataPpg((long)startTime * 1000 + peak.get(i) * 10, amp.get(i))); } LOG.debug("Buffer remaining {}", buffer.remaining()); } private ArrayList decodePeak(byte countShort, ByteBuffer buffer) throws IllegalArgumentException { if (countShort == 0) throw new IllegalArgumentException("Number of short to generate is invalid"); if (buffer.remaining() < 1) throw new IllegalArgumentException("Compression tag is missing"); byte tag = buffer.get(); ArrayList al = new ArrayList(); if (tag == TAG_COMPRESSION_RAW) { if (buffer.remaining() < countShort * 2) throw new IllegalArgumentException("Not enough elements in buffer"); while (countShort > 0) { al.add(buffer.getShort()); countShort--; } } else if (tag == TAG_COMPRESSION_COMP) { short working = buffer.getShort(); al.add(working); countShort--; while (countShort > 0) { byte c = buffer.get(); if (c == TAG_COMPRESSION_RESTART) { if (buffer.remaining() < 2) throw new IllegalArgumentException("Not enough elements in buffer"); working = buffer.getShort(); } else { working += c; } al.add(working); countShort--; } } else { throw new IllegalArgumentException("Compression " + String.format("%02x", tag) + " is unsupported"); } return al; } public ArrayList decodeAmp(byte countShort, ByteBuffer buffer) throws IllegalArgumentException { if (countShort == 0) throw new IllegalArgumentException("Number of short to generate is invalid"); if (buffer.remaining() < 1) throw new IllegalArgumentException("Compression tag is missing"); byte tag = buffer.get(); ArrayList al = new ArrayList(); if (tag == TAG_COMPRESSION_RAW) { if (buffer.remaining() < countShort * 2) throw new IllegalArgumentException("Not enough elements in buffer"); while (countShort > 0) { al.add(buffer.getShort()); countShort--; } } else if (tag == TAG_COMPRESSION_COMP) { if (buffer.remaining() < 2) throw new IllegalArgumentException("Offset buffer is missing"); short offset = buffer.getShort(); short working = 0; while (buffer.remaining() > 0) { byte c = buffer.get(); if (c == TAG_COMPRESSION_RESTART) { if (buffer.remaining() < 2) throw new IllegalArgumentException("Raw buffer is missing"); working = (short)(offset + buffer.getShort()); } else { working = (short)(offset + c); } al.add(working); } } else { throw new IllegalArgumentException("Compression " + String.format("%02x", tag) + " is unsupported"); } return al; } public void parsePpgData(byte[] data) { LOG.debug("Decoding PPG: len= {}, data = {}", data.length, GB.hexdump(data)); try { decodePpg(data); } catch (Exception e) { LOG.error("Failed to parse TrueSleep PPG data: {}", e.toString()); } } public void parseAccData(byte[] data) { LOG.debug("Decoding ACC: len= {}, data = {}", data.length, GB.hexdump(data)); try { decodeAcc(data); } catch (Exception e) { LOG.error("Failed to parse TrueSleep ACC data: {}", e.toString()); } } } public static TruSleepData parseData(byte[] data) { /* Format: - Zero padded - ACC data interleaved with PPG data - PPG data is compressed and has variable length */ TruSleepData sleepData = new TruSleepData(); ByteBuffer buffer = ByteBuffer.wrap(data); buffer.order(ByteOrder.LITTLE_ENDIAN); byte c; while (buffer.remaining() > 0) { c = buffer.get(); // Skip over padding if (c != TAG_ACC && c != TAG_PPG) continue; // The tag identifies the data packet if (c == TAG_ACC) { if (buffer.remaining() < 1) break; // ACC has 1 byte length byte length = buffer.get(); if (buffer.remaining() < length) break; byte[] accData = new byte[length]; buffer.get(accData); sleepData.parseAccData(accData); } else if (c == TAG_PPG) { if (buffer.remaining() < 2) break; // PPG has 2 byte length short length = buffer.getShort(); if (buffer.remaining() < length) break; byte[] ppgData = new byte[length]; buffer.get(ppgData); sleepData.parsePpgData(ppgData); } } return sleepData; } public static TruSleepStatus[] parseState(byte[] stateData) { /* Format: - Start time (int) - End time (int) - Unknown (short) - Unknown (byte) - Padding (5 bytes) Could be multiple available */ ByteBuffer buffer = ByteBuffer.wrap(stateData); buffer.order(ByteOrder.LITTLE_ENDIAN); TruSleepStatus[] retv = new TruSleepStatus[buffer.remaining() / 0x10]; int c = 0; while (stateData.length - buffer.position() >= 0x10) { int startTime = buffer.getInt(); int endTime = buffer.getInt(); // Throw away for now because we don't know what it means, and we don't think we can implement this soon buffer.get(); buffer.get(); buffer.get(); buffer.get(); buffer.get(); buffer.get(); buffer.get(); buffer.get(); retv[c++] = new TruSleepStatus(startTime, endTime); } return retv; } private static void analyzeOneTimeframe(HuaweiSupportProvider provider, TruSleepStatus status, ArrayList acc, ArrayList ppg) { java.util.Date rangeStartTime = new java.util.Date((long)status.startTime*1000); java.util.Date rangeEndTime = new java.util.Date((long)status.endTime*1000); LOG.debug("FIXME: Analyse sleep range {} - {} not implemented", rangeStartTime, rangeEndTime); //FIXME: Sleep analysis algorithm implemented here for (HuaweiTruSleepParser.TruSleepDataAcc i : acc) { java.util.Date startTime = new java.util.Date((long)i.startTime*1000); LOG.debug("TruSleepDataAcc {} : {}", startTime, String.format("%04x", i.flags)); } for (HuaweiTruSleepParser.TruSleepDataPpg i : ppg) { java.util.Date startTime = new java.util.Date((long) i.startTime); LOG.debug("TruSleepDataPpg {} : {}", startTime, String.format("%04x", i.flags)); } } public static void analyze(HuaweiSupportProvider provider, TruSleepStatus[] status, TruSleepData data) { // Analyse each time range as specified by TruSleepStatus for (TruSleepStatus s : status) { ArrayList acc = new ArrayList<>(); ArrayList ppg = new ArrayList<>(); // Collect Accelerometer data for current time frame for (TruSleepDataAcc i : data.dataACCs) { if (i.startTime >= s.startTime && i.startTime <= s.endTime) acc.add(i); } // Collect PPG data for current time frame for (TruSleepDataPpg i : data.dataPPGs) { if (i.startTime >= s.startTime && i.startTime <= s.endTime) ppg.add(i); } // Analyse time frame analyzeOneTimeframe(provider, s, acc, ppg); } } public static class SleepFileDownloadCallback extends HuaweiFileDownloadManager.FileDownloadCallback { private byte[] statusData; private byte[] sleepData; private boolean statusSynced; private boolean dataSynced; protected HuaweiSupportProvider provider; public SleepFileDownloadCallback(HuaweiSupportProvider provider) { this.dataSynced = false; this.statusSynced = false; this.provider = provider; } public void syncComplete(byte[] statusData, byte[] sleepData) { } @Override public void downloadException(HuaweiFileDownloadManager.HuaweiFileDownloadException e) { if (e.fileRequest == null) { LOG.error("Failed to download TruSleep file: {}", e.toString()); syncComplete(statusData, sleepData); return; } if (e.fileRequest.getFileType() == HuaweiFileDownloadManager.FileType.SLEEP_STATE) { statusSynced = true; } else if (e.fileRequest.getFileType() == HuaweiFileDownloadManager.FileType.SLEEP_DATA) { dataSynced = true; } if (statusSynced && dataSynced) syncComplete(statusData, sleepData); } @Override public void downloadComplete(HuaweiFileDownloadManager.FileRequest fileRequest) { if (fileRequest.getFileType() == HuaweiFileDownloadManager.FileType.SLEEP_STATE) { statusData = fileRequest.getData(); statusSynced = true; } else if (fileRequest.getFileType() == HuaweiFileDownloadManager.FileType.SLEEP_DATA) { sleepData = fileRequest.getData(); dataSynced = true; } LOG.debug("Downloaded TruSleep file {}", fileRequest.getFileType()); if (statusSynced && dataSynced) syncComplete(statusData, sleepData); } } }