/* Copyright (C) 2024 Damien Gaignon, Martin.JM, Vitalii Tomin 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.packets; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HeartRateZonesConfig; import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiPacket; import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiReportThreshold; import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiRunPaceConfig; import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiTLV; public class FitnessData { public static final byte id = 0x07; public static class MotionGoal { public static final byte id = 0x01; public static class Request extends HuaweiPacket { public Request(ParamsProvider paramsProvider, byte goalType, byte frameType, int stepGoal, int calorieGoal, short durationGoal) { super(paramsProvider); this.serviceId = FitnessData.id; this.commandId = id; frameType = (frameType == 0x01) ? 0x01 : Type.motion; HuaweiTLV subTlv = new HuaweiTLV() .put(0x03, goalType) .put(0x04, frameType); stepGoal = ((Type.data & 0x01) != 0x00) ? stepGoal : 0xffffffff; if (stepGoal != 0xffffffff) subTlv.put(0x05, stepGoal); int calorieGoalFinal = ((Type.data & 0x02) != 0x00) ? calorieGoal : 0xffffffff; if (calorieGoalFinal != 0xffffffff) { subTlv.put(0x06, calorieGoalFinal); } else if (frameType == 0x01) { subTlv.put(0x06, stepGoal / 0x1e); } int distanceGoal = ((Type.data & 0x04) != 0x00) ? durationGoal : 0xffffffff; if (distanceGoal != 0xffffffff) { subTlv.put(0x07, distanceGoal); } else if (frameType == 0x01) { subTlv.put(0x06, stepGoal); } short durationGoalFinal = ((Type.data & 0x08) != 0x00) ? durationGoal : 0xffffffff; if (durationGoalFinal != 0xffffffff) { subTlv.put(0x08, durationGoalFinal); } HuaweiTLV containerTlv = new HuaweiTLV().put(0x82, subTlv); this.tlv = new HuaweiTLV() .put(0x81, containerTlv); } } } public static class UserInfo { public static final byte id = 0x02; public static class UserInfoData { public static final int GENDER_MALE = 1; public static final int GENDER_FEMALE = 2; public static final int GENDER_UNKNOWN = 3; private static final float RUN_CF = 0.83f; private static final float WALK_CF = 0.42f; public static final int DEFAULT_HEIGHT = 175; public static final int DEFAULT_WEIGHT = 65; private final int height; private final float weight; private final int age; private final int birthday; private final byte gender; private final boolean unknownGenderSupported; private final boolean vo2Supported; private final int vo2Max; private final int vo2Time; private final boolean precisionWeightSupported; public UserInfoData(int height, float weight, int age, int birthday, byte gender, boolean unknownGenderSupported, boolean vo2Supported, int vo2Max, int vo2Time, boolean precisionWeightSupported) { this.height = height; this.weight = weight; this.age = age; this.birthday = birthday; this.gender = gender; this.unknownGenderSupported = unknownGenderSupported; this.vo2Supported = vo2Supported; this.vo2Max = vo2Max; this.vo2Time = vo2Time; this.precisionWeightSupported = precisionWeightSupported; } public int getHeight() { if (this.height > 0) return this.height; return DEFAULT_HEIGHT; } public float getWeight() { if (this.weight > 0) return this.weight; return DEFAULT_WEIGHT; } public int getAge() { return age; } public int getBirthday() { return birthday; } public byte getGender() { return gender; } public boolean isUnknownGenderSupported() { return unknownGenderSupported; } public boolean isVo2Supported() { return vo2Supported; } public int getVo2Max() { return vo2Max; } public int getVo2Time() { return vo2Time; } public boolean isPrecisionWeightSupported() { return precisionWeightSupported; } public boolean isGenderValid() { return this.gender == GENDER_MALE || this.gender == GENDER_FEMALE || this.gender == GENDER_UNKNOWN; } public boolean isBirthdayValid() { return this.birthday > 0; } } public static class Request extends HuaweiPacket { public Request(ParamsProvider paramsProvider, UserInfoData info) { super(paramsProvider); this.serviceId = FitnessData.id; this.commandId = id; int height = info.getHeight(); float weight = info.getWeight(); byte bmiWalk = (byte) Math.round(UserInfoData.WALK_CF * height); byte bmiRun = (byte) Math.round(UserInfoData.RUN_CF * height); this.tlv = new HuaweiTLV() .put(0x01, (byte) height) .put(0x02, (byte) weight) .put(0x03, (byte) info.getAge()); if (info.isBirthdayValid()) this.tlv.put(0x04, info.getBirthday()); if (info.isGenderValid()) { if (info.unknownGenderSupported || info.gender != UserInfoData.GENDER_UNKNOWN) this.tlv.put(0x05, info.gender); } this.tlv.put(0x06, bmiWalk); this.tlv.put(0x07, bmiRun); if (info.vo2Supported) { this.tlv.put(0x08, info.getVo2Max()); this.tlv.put(0x09, info.getVo2Time()); } if (info.precisionWeightSupported) { int precisionWeight = Math.round(weight * 100f); this.tlv.put(0x0b, precisionWeight); } } } } public static class MessageCount { public static final byte sleepId = 0x0C; public static final byte stepId = 0x0A; public static class Request extends HuaweiPacket { public Request( ParamsProvider paramsProvider, byte commandId, int start, int end ) { super(paramsProvider); this.serviceId = FitnessData.id; this.commandId = commandId; this.tlv = new HuaweiTLV() .put(0x81) .put(0x03, start) .put(0x04, end); this.complete = true; } } public static class Response extends HuaweiPacket { public short count; public Response(ParamsProvider paramsProvider) { super(paramsProvider); } @Override public void parseTlv() throws ParseException { this.count = this.tlv.getObject(0x81).getShort(0x02); this.complete = true; } } } public static class MessageData { public static final byte sleepId = 0x0D; public static final byte stepId = 0x0B; public static class Request extends HuaweiPacket { public Request(ParamsProvider paramsProvider, byte commandId, short count) { super(paramsProvider); this.serviceId = FitnessData.id; this.commandId = commandId; this.tlv = new HuaweiTLV() .put(0x81, new HuaweiTLV() .put(0x02, count) ); this.complete = true; } } public static class SleepResponse extends HuaweiPacket { public static class SubContainer { public byte type; public byte[] timestamp; @Override public String toString() { return "SubContainer{" + "type=" + type + ", timestamp=" + Arrays.toString(timestamp) + '}'; } } public short number; public List containers; public SleepResponse(ParamsProvider paramsProvider) { super(paramsProvider); this.serviceId = FitnessData.id; this.commandId = sleepId; } @Override public void parseTlv() throws ParseException { HuaweiTLV container = this.tlv.getObject(0x81); List subContainers = container.getObjects(0x83); this.number = container.getShort(0x02); this.containers = new ArrayList<>(); for (HuaweiTLV subContainerTlv : subContainers) { SubContainer subContainer = new SubContainer(); subContainer.type = subContainerTlv.getByte(0x04); subContainer.timestamp = subContainerTlv.getBytes(0x05); this.containers.add(subContainer); } } } public static class StepResponse extends HuaweiPacket { public static class SubContainer { public static class TV { public final byte bitmap; public final byte tag; public final short value; public TV(byte bitmap, byte tag, short value) { this.bitmap = bitmap; this.tag = tag; this.value = value; } @Override public String toString() { return "TV{" + "bitmap=" + bitmap + ", tag=" + tag + ", value=" + value + '}'; } } /* * Data directly from packet */ public byte timestampOffset; public byte[] data; /* * Inferred data */ public int timestamp; public List parsedData = null; public String parsedDataError = ""; public int steps = -1; public int calories = -1; public int distance = -1; public int heartrate = -1; public int spo = -1; public List unknownTVs = null; @Override public String toString() { return "SubContainer{" + "timestampOffset=" + timestampOffset + ", data=" + Arrays.toString(data) + ", timestamp=" + timestamp + ", parsedData=" + parsedData + ", parsedDataError='" + parsedDataError + '\'' + ", steps=" + steps + ", calories=" + calories + ", distance=" + distance + ", spo=" + spo + ", unknownTVs=" + unknownTVs + '}'; } } public short number; public int timestamp; public List containers; private static final List singleByteTagListBitmap1 = new ArrayList<>(); static { singleByteTagListBitmap1.add((byte) 0x20); singleByteTagListBitmap1.add((byte) 0x40); } public StepResponse(ParamsProvider paramsProvider) { super(paramsProvider); this.serviceId = FitnessData.id; this.commandId = stepId; } @Override public void parseTlv() throws ParseException { HuaweiTLV container = this.tlv.getObject(0x81); List subContainers = container.getObjects(0x84); this.number = container.getShort(0x02); this.timestamp = container.getInteger(0x03); this.containers = new ArrayList<>(); for (HuaweiTLV subContainerTlv : subContainers) { SubContainer subContainer = new SubContainer(); subContainer.timestampOffset = subContainerTlv.getByte(0x05); subContainer.timestamp = this.timestamp + 60 * subContainer.timestampOffset; subContainer.data = subContainerTlv.getBytes(0x06); parseData(subContainer, subContainer.data); this.containers.add(subContainer); } } private static void parseData(SubContainer returnValue, byte[] data) { int i = 0; if (data.length <= 0) { returnValue.parsedData = null; returnValue.parsedDataError = "Data is missing feature bitmap."; return; } byte featureBitmap1 = data[i++]; byte featureBitmap2 = 0; if ((featureBitmap1 & 128) != 0) { if (data.length <= i) { returnValue.parsedData = null; returnValue.parsedDataError = "Data is missing second feature bitmap."; return; } featureBitmap2 = data[i++]; } returnValue.parsedData = new ArrayList<>(); returnValue.unknownTVs = new ArrayList<>(); // The greater than zero check is because Java is always signed, so we only check 7 bits for (byte bitToCheck = 1; bitToCheck > 0; bitToCheck <<= 1) { if ((featureBitmap1 & bitToCheck) != 0) { short value; if (singleByteTagListBitmap1.contains(bitToCheck)) { if (data.length - 1 < i) { returnValue.parsedData = null; returnValue.parsedDataError = "Data is too short for selected features."; return; } value = data[i++]; } else { if (data.length - 2 < i) { returnValue.parsedData = null; returnValue.parsedDataError = "Data is too short for selected features."; return; } value = (short) ((data[i++] & 0xFF) << 8 | (data[i++] & 0xFF)); } // The bitToCheck is used as tag, which may not be optimal, but works SubContainer.TV tv = new SubContainer.TV((byte) 1, bitToCheck, value); returnValue.parsedData.add(tv); if (bitToCheck == 0x02) returnValue.steps = value; else if (bitToCheck == 0x04) returnValue.calories = value; else if (bitToCheck == 0x08) returnValue.distance = value; else if (bitToCheck == 0x40) returnValue.heartrate = value; else returnValue.unknownTVs.add(tv); } } if (featureBitmap2 != 0) { // We want to check 8 bits here, and java is java, so we use a short for (short bitToCheck = 1; bitToCheck < 0x0100; bitToCheck <<= 1) { if ((featureBitmap2 & bitToCheck) != 0) { if (data.length - 1 < i) { returnValue.parsedData = null; returnValue.parsedDataError = "Data is too short for selected features."; return; } byte value = data[i++]; SubContainer.TV tv = new SubContainer.TV((byte) 2, (byte) bitToCheck, value); returnValue.parsedData.add(tv); if (bitToCheck == 0x01) returnValue.spo = value; else returnValue.unknownTVs.add(tv); } } } } } } public static class FitnessTotals { public static final byte id = 0x03; public static class Request extends HuaweiPacket { public Request(ParamsProvider paramsProvider) { super(paramsProvider); this.serviceId = FitnessData.id; this.commandId = id; this.tlv = new HuaweiTLV() .put(0x01); this.complete = true; } } public static class Response extends HuaweiPacket { public int totalSteps = 0; public int totalCalories = 0; public int totalDistance = 0; public Response(ParamsProvider paramsProvider) { super(paramsProvider); this.serviceId = FitnessData.id; this.commandId = id; } @Override public void parseTlv() throws ParseException { HuaweiTLV container = this.tlv.getObject(0x81); List containers = container.getObjects(0x83); for (HuaweiTLV tlv : containers) { if (tlv.contains(0x05)) totalSteps += tlv.getInteger(0x05); if (tlv.contains(0x06)) totalCalories += tlv.getShort(0x06); if (tlv.contains(0x07)) totalDistance += tlv.getInteger(0x07); } this.complete = true; } } } public static class ActivityReminder { public static final byte id = 0x07; public static class Request extends HuaweiPacket { public Request( ParamsProvider paramsProvider, boolean longSitSwitch, byte longSitInterval, byte[] longSitStart, byte[] longSitEnd, byte cycle ) { super(paramsProvider); this.serviceId = FitnessData.id; this.commandId = id; this.tlv = new HuaweiTLV() .put(0x81, new HuaweiTLV() .put(0x02, longSitSwitch) .put(0x03, longSitInterval) .put(0x04, longSitStart) .put(0x05, longSitEnd) .put(0x06, cycle) ); this.complete = true; } } } public static class DeviceReportThreshold { public static final byte id = 0xe; public static class Request extends HuaweiPacket { public Request(ParamsProvider paramsProvider, List thresholds) { super(paramsProvider); this.serviceId = FitnessData.id; this.commandId = id; HuaweiTLV subTlv = new HuaweiTLV(); for (HuaweiReportThreshold th : thresholds) { subTlv.put(0x02, th.getBytes()); } this.tlv = new HuaweiTLV().put(0x81, subTlv); this.complete = true; } } } public static class HeartRateZoneConfigPacket { // It can use two IDs with basically the same format. public static final byte id_simple = 0x13; public static final byte id_extended = 0x21; public static class Request extends HuaweiPacket { private Request( ParamsProvider paramsProvider, byte id, HeartRateZonesConfig heartRateZonesConfig ) { super(paramsProvider); this.serviceId = FitnessData.id; this.commandId = id; HuaweiTLV subTlv = new HuaweiTLV(). put(0x08, heartRateZonesConfig.getWarningEnable()); if ( heartRateZonesConfig.hasValidMHRData() && heartRateZonesConfig.getWarningHRLimit() > 0 && heartRateZonesConfig.getMaxHRThreshold() > 0 ) { subTlv .put(0x09, (byte) heartRateZonesConfig.getWarningHRLimit()) .put(0x02, (byte) heartRateZonesConfig.getMHRWarmUp()) .put(0x03, (byte) heartRateZonesConfig.getMHRFatBurning()) .put(0x04, (byte) heartRateZonesConfig.getMHRAerobic()) .put(0x05, (byte) heartRateZonesConfig.getMHRAnaerobic()) .put(0x06, (byte) heartRateZonesConfig.getMHRExtreme()) .put(0x07, (byte) heartRateZonesConfig.getMaxHRThreshold()) .put(0x0b, (byte) heartRateZonesConfig.getMaxHRThreshold()); } if (id == id_extended && heartRateZonesConfig.hasValidHRRData()) { subTlv .put(0x0d, (byte) heartRateZonesConfig.getHRRBasicAerobic()) .put(0x0e, (byte) heartRateZonesConfig.getHRRAdvancedAerobic()) .put(0x0f, (byte) heartRateZonesConfig.getHRRLactate()) .put(0x10, (byte) heartRateZonesConfig.getHRRBasicAnaerobic()) .put(0x11, (byte) heartRateZonesConfig.getHRRAdvancedAnaerobic()); } if (id == id_extended && heartRateZonesConfig.getRestHeartRate() > 0) { subTlv .put(0x0a, (byte) heartRateZonesConfig.getCalculateMethod()) .put(0x0c, (byte) heartRateZonesConfig.getRestHeartRate()); } this.tlv = new HuaweiTLV().put(0x81, subTlv); this.complete = true; } public static Request requestSimple(ParamsProvider paramsProvider, HeartRateZonesConfig heartRateZonesConfig) { return new Request(paramsProvider, id_simple, heartRateZonesConfig); } public static Request requestExtended(ParamsProvider paramsProvider, HeartRateZonesConfig heartRateZonesConfig) { return new Request(paramsProvider, id_extended, heartRateZonesConfig); } } } public static class TruSleep { public static final byte id = 0x16; public static class Request extends HuaweiPacket { public Request(ParamsProvider paramsProvider, boolean truSleepSwitch) { super(paramsProvider); this.serviceId = FitnessData.id; this.commandId = id; this.tlv = new HuaweiTLV() .put(0x01, truSleepSwitch); this.complete = true; } } } public static class EnableAutomaticHeartrate { public static final byte id = 0x17; public static class Request extends HuaweiPacket { public Request(ParamsProvider paramsProvider, boolean enableAutomaticHeartrate) { super(paramsProvider); this.serviceId = FitnessData.id; this.commandId = id; this.tlv = new HuaweiTLV() .put(0x01, enableAutomaticHeartrate); this.isEncrypted = true; this.complete = true; } } } public static class NotifyRestHeartRate { public static final byte id = 0x23; public static class Request extends HuaweiPacket { public Request(ParamsProvider paramsProvider) { super(paramsProvider); this.serviceId = FitnessData.id; this.commandId = id; this.tlv = new HuaweiTLV() .put(0x01, 0x01); this.complete = true; } } } public static class EnableAutomaticSpo { public static final byte id = 0x24; public static class Request extends HuaweiPacket { public Request(ParamsProvider paramsProvider, boolean enableAutomaticSpo) { super(paramsProvider); this.serviceId = FitnessData.id; this.commandId = id; this.tlv = new HuaweiTLV() .put(0x01, enableAutomaticSpo); this.isEncrypted = true; this.complete = true; } } } public static class RunPaceConfig { public static final byte id = 0x28; public static class Request extends HuaweiPacket { public Request(ParamsProvider paramsProvider, final HuaweiRunPaceConfig runPaceConfig) { super(paramsProvider); this.serviceId = FitnessData.id; this.commandId = id; this.tlv = new HuaweiTLV() .put(0x01, (short) runPaceConfig.getZone1JogMin()) .put(0x02, (short) runPaceConfig.getZone2MarathonMin()) .put(0x03, (short) runPaceConfig.getZone3LactateThresholdMin()) .put(0x04, (short) runPaceConfig.getZone4AnaerobicMin()) .put(0x05, (short) runPaceConfig.getZone5HIITRunMin()) .put(0x06, (short) runPaceConfig.getZone5HIITRunMax()); this.complete = true; } } public static class Response extends HuaweiPacket { public boolean isOk; public Response(ParamsProvider paramsProvider) { super(paramsProvider); this.serviceId = FitnessData.id; this.commandId = id; } @Override public void parseTlv() throws ParseException { isOk = this.tlv.getInteger(0x7f) == 0x000186A0; } } } public static class MediumToStrengthThreshold { public static final byte id = 0x29; public static class Request extends HuaweiPacket { public Request(ParamsProvider paramsProvider, byte walkRun, byte climb, byte heartRate, byte cycleSpeed, byte sample, byte countLength, int walkRunSpeed, int walkRunWithHeartRate) { super(paramsProvider); this.serviceId = FitnessData.id; this.commandId = id; if (walkRun < 0x00 || walkRun > 0xc8) walkRun = 0x6E; if (climb < 0x0 || climb > 0xc8) climb = 0x3c; if (heartRate < 0x0 || heartRate > 0x64) heartRate = 0x40; if (cycleSpeed < 0x0 || cycleSpeed > 0xff) cycleSpeed = 0x50; if (sample < 0x1 || sample > 0xa) sample = 0x3; if (countLength < 0x1 || countLength > 0xa) countLength = 0x5; if (countLength < sample) countLength = sample; this.tlv = new HuaweiTLV() .put(0x01, walkRun) .put(0x02, climb) .put(0x03, heartRate) .put(0x04, cycleSpeed) .put(0x05, sample) .put(0x06, countLength); if (walkRunSpeed != -1) { this.tlv.put(0x07, (byte) walkRunSpeed); } if (walkRunWithHeartRate != -1) { this.tlv.put(0x08, (byte) walkRunWithHeartRate); } this.complete = true; } } } public static class SkinTemperatureMeasurement { public static final byte id = 0x2a; public static class Request extends HuaweiPacket { public Request(ParamsProvider paramsProvider, boolean temperatureSwitch) { super(paramsProvider); this.serviceId = FitnessData.id; this.commandId = id; this.tlv = new HuaweiTLV() .put(0x01, (byte) 0x01) .put(0x02, temperatureSwitch); this.complete = true; } } } public static class Type { // TODO: enum? public static final byte goal = 0x01; public static final byte motion = 0x00; public static final byte data = 0x01; } }