/* Copyright (C) 2020-2024 Andreas Shimokawa, Benjamin Swartley, Daniel Dakhno 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.qhybrid.parser; import nodomain.freeyourgadget.gadgetbridge.entities.HybridHRSpo2Sample; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.ArrayList; import java.util.Map; public class ActivityFileParser { // state flags; int heartRateQuality; ActivityEntry.WEARING_STATE wearingState = ActivityEntry.WEARING_STATE.WEARING; int currentTimestamp = 0; // Aligns with `e2 04` from my testing ActivityEntry currentSample = null; int currentId = 1; public Map.Entry, ArrayList> parseFile(byte[] file) { ByteBuffer buffer = ByteBuffer.wrap(file); buffer.order(ByteOrder.LITTLE_ENDIAN); // read file version short version = buffer.getShort(2); if (version != 22) throw new RuntimeException("File version " + version + ", 16 required"); this.currentTimestamp = buffer.getInt(8); short timeOffsetMinutes = buffer.getShort(12); short fileId = buffer.getShort(16); buffer.position(52); // Seem to be another 32 bytes after the initial 20 stop ArrayList samples = new ArrayList<>(); ArrayList spo2Samples = new ArrayList<>(); finishCurrentPacket(samples); while (buffer.position() < buffer.capacity() - 4) { byte next = buffer.get(); switch (next) { case (byte) 0xCE: parseWearByte(buffer.get()); byte f1 = buffer.get(); byte f2 = buffer.get(); if (f1 == (byte) 0xE2 && f2 == (byte) 0x04) { int timestamp = buffer.getInt(); buffer.getShort(); // duration buffer.getShort(); // minutes offset this.currentTimestamp = timestamp; } else if (f1 == (byte) 0xD3) { // Workout-related int hr1 = f2 & 0xFF; // Might be min HR during workout sometimes? byte[] infoB = new byte[2]; buffer.get(infoB); byte v1 = buffer.get(); byte v2 = buffer.get(buffer.position()); // Could be important for 11 byte packet if (v1 == (byte) 0xDF) { int hr2 = v2 & 0xFF; // Max HR during workout - extra data inside? buffer.get(); if (infoB[0] == (byte) 0x08) buffer.get(new byte[11]); // ? else if (!elemValidFlags(buffer.get(buffer.position() + 4))) buffer.get(new byte[3]); } else if (v1 == (byte) 0xE2 && v2 == (byte) 0x04) { buffer.get(new byte[13]); if (!elemValidFlags(buffer.get(buffer.position()))) buffer.get(new byte[3]); } else if (!elemValidFlags(buffer.get(buffer.position() + 4))) buffer.get(); } else if (f1 == (byte) 0xCF || f1 == (byte) 0xDF) { continue; // Not sure what to do with this } else if (f1 == (byte) 0xD6) { HybridHRSpo2Sample spo2Sample = new HybridHRSpo2Sample(); spo2Sample.setTimestamp(currentTimestamp * 1000L); spo2Sample.setSpo2(buffer.get() & 0xFF); spo2Samples.add(spo2Sample); buffer.get(new byte[3]); // Likely something to do with sample statistics } else if (f1 == (byte) 0xFE && f2 == (byte) 0xFE) { if (buffer.get(buffer.position()) == (byte) 0xFE) { buffer.get(); } // WHY? } else if (elemValidFlags(buffer.get(buffer.position() + 2))) { parseVariabilityBytes(f1, f2); int heartRate = buffer.get() & 0xFF; int calories = buffer.get() & 0xFF; boolean isActive = (calories & 0x40) == 0x40; calories &= 0x3F; currentSample.heartRate = heartRate; currentSample.calories = calories; currentSample.isActive = isActive; finishCurrentPacket(samples); continue; } if (buffer.position() > buffer.capacity() - 4) { continue; } parseVariabilityBytes(buffer.get(), buffer.get()); int heartRate = buffer.get() & 0xFF; int calories = buffer.get() & 0xFF; boolean isActive = (calories & 0x40) == 0x40; // upper two bits calories &= 0x3F; // delete upper two bits currentSample.heartRate = heartRate; currentSample.calories = calories; currentSample.isActive = isActive; finishCurrentPacket(samples); break; case (byte) 0xC2: // Or `c2 X` `ac X` as per #2884 buffer.get(new byte[3]); break; case (byte) 0xE2: buffer.get(new byte[9]); if (!elemValidFlags(buffer.get(buffer.position()))) { buffer.get(new byte[6]); } break; case (byte) 0xE0: // Workout Info for (int i = 0; i < 14; i++) { buffer.get(); // Attribute # byte size = buffer.get(); buffer.get(new byte[size & 0xFF]); // Can eventually use this, nowhere to pass for now } break; case (byte) 0xDD: buffer.get(new byte[20]); // No idea what this is break; case (byte) 0xD6: buffer.get(); // Likely some statistic, notably different from 0xCE 0xD6 HybridHRSpo2Sample spo2Sample = new HybridHRSpo2Sample(); spo2Sample.setTimestamp(currentTimestamp * 1000L); spo2Sample.setSpo2(buffer.get() & 0xFF); spo2Samples.add(spo2Sample); break; case (byte) 0xCB: // Very rare, may even be removed case (byte) 0xCC: // Around 73 or 74 case (byte) 0xCF: // Almost always 128 (0x80) buffer.get(); break; default: ; } } return Map.entry(samples, spo2Samples); } private static boolean elemValidFlags(byte value) { for (byte i : new byte[]{(byte) 0xCE, (byte) 0xDD, (byte) 0xCB, (byte) 0xCC, (byte) 0xCF, (byte) 0xD6, (byte) 0xE2}) if (value == i) return true; return false; } private void parseVariabilityBytes(byte lower, byte higher){ if((lower & 0b0000001) == 0b0000001){ currentSample.maxVariability = (higher & 0b00000011) * 25 + 1; currentSample.stepCount = lower & 0b1110; if((lower & 0b10000000) == 0b10000000){ int factor = (lower >> 4) & 0b111; currentSample.variability = 512 + factor * 64 + (higher >> 2 & 0b111111); }else { currentSample.variability = lower & 0b01110000; currentSample.variability <<= 2; currentSample.variability |= (higher >> 2) & 0b111111; } }else{ currentSample.stepCount = lower & 0b11111110; currentSample.variability = (int) higher * (int) higher * 64; currentSample.maxVariability = 10000; } } private void parseWearByte(byte wearArg) { byte wearBits = (byte)((wearArg & 0b00011000) >> 3); if (wearBits == 0) this.wearingState = ActivityEntry.WEARING_STATE.NOT_WEARING; else if (wearBits == 1) this.wearingState = ActivityEntry.WEARING_STATE.WEARING; else this.wearingState = ActivityEntry.WEARING_STATE.UNKNOWN; byte heartRateQualityBits = (byte)((wearArg & 0b11100000) >> 5); this.heartRateQuality = heartRateQualityBits; } private void finishCurrentPacket(ArrayList samples) { if (currentSample != null) { currentSample.timestamp = currentTimestamp; currentSample.heartRateQuality = this.heartRateQuality; currentSample.wearingState = wearingState; currentTimestamp += 60; samples.add(currentSample); currentSample = null; } this.currentSample = new ActivityEntry(); this.currentSample.id = currentId++; } }