/* Copyright (C) 2024 Arjan Schrijver 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.colmi; import android.content.Context; import android.content.Intent; import android.widget.Toast; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.Calendar; import java.util.List; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences; import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiActivitySampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiHeartRateSampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiHrvValueSampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiSleepSessionSampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiSleepStageSampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiSpo2SampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.colmi.samples.ColmiStressSampleProvider; import nodomain.freeyourgadget.gadgetbridge.entities.ColmiActivitySample; import nodomain.freeyourgadget.gadgetbridge.entities.ColmiHeartRateSample; import nodomain.freeyourgadget.gadgetbridge.entities.ColmiHrvValueSample; import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSleepSessionSample; import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSleepStageSample; import nodomain.freeyourgadget.gadgetbridge.entities.ColmiSpo2Sample; import nodomain.freeyourgadget.gadgetbridge.entities.ColmiStressSample; import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; import nodomain.freeyourgadget.gadgetbridge.entities.Device; import nodomain.freeyourgadget.gadgetbridge.entities.User; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; import nodomain.freeyourgadget.gadgetbridge.model.DeviceService; import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; import nodomain.freeyourgadget.gadgetbridge.service.devices.colmi.ColmiR0xDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.util.GB; import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; public class ColmiR0xPacketHandler { private static final Logger LOG = LoggerFactory.getLogger(ColmiR0xPacketHandler.class); public static void hrIntervalSettings(ColmiR0xDeviceSupport support, byte[] value) { if (value[1] == ColmiR0xConstants.PREF_WRITE) return; // ignore empty response when writing setting boolean enabled = value[2] == 0x01; int minutes = value[3]; LOG.info("Received HR interval preference: {} minutes, enabled={}", minutes, enabled); GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences(); eventUpdatePreferences.withPreference( DeviceSettingsPreferenceConst.PREF_HEARTRATE_MEASUREMENT_INTERVAL, String.valueOf(minutes * 60) ); support.evaluateGBDeviceEvent(eventUpdatePreferences); } public static void spo2Settings(ColmiR0xDeviceSupport support, byte[] value) { boolean enabled = value[2] == 0x01; LOG.info("Received SpO2 preference: {}", enabled ? "enabled" : "disabled"); GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences(); eventUpdatePreferences.withPreference( DeviceSettingsPreferenceConst.PREF_SPO2_ALL_DAY_MONITORING, enabled ); support.evaluateGBDeviceEvent(eventUpdatePreferences); } public static void stressSettings(ColmiR0xDeviceSupport support, byte[] value) { boolean enabled = value[2] == 0x01; LOG.info("Received stress preference: {}", enabled ? "enabled" : "disabled"); GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences(); eventUpdatePreferences.withPreference( DeviceSettingsPreferenceConst.PREF_HEARTRATE_STRESS_MONITORING, enabled ); support.evaluateGBDeviceEvent(eventUpdatePreferences); } public static void hrvSettings(ColmiR0xDeviceSupport support, byte[] value) { boolean enabled = value[2] == 0x01; LOG.info("Received HRV preference: {}", enabled ? "enabled" : "disabled"); GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences(); eventUpdatePreferences.withPreference( DeviceSettingsPreferenceConst.PREF_HRV_ALL_DAY_MONITORING, enabled ); support.evaluateGBDeviceEvent(eventUpdatePreferences); } public static void goalsSettings(byte[] value) { int steps = BLETypeConversions.toUint32(value[2], value[3], value[4], (byte) 0); int calories = BLETypeConversions.toUint32(value[5], value[6], value[7], (byte) 0); int distance = BLETypeConversions.toUint32(value[8], value[9], value[10], (byte) 0); int sport = BLETypeConversions.toUint16(value[11], value[12]); int sleep = BLETypeConversions.toUint16(value[13], value[14]); LOG.info("Received goals preferences: {} steps, {} calories, {}m distance, {}min sport, {}min sleep", steps, calories, distance, sport, sleep); } public static void liveHeartRate(GBDevice device, Context context, byte[] value) { int errorCode = value[2]; int hrResponse = value[3] & 0xff; switch (errorCode) { case 0: LOG.info("Received live heart rate response: {} bpm", hrResponse); break; case 1: GB.toast(context.getString(R.string.smart_ring_measurement_error_worn_incorrectly), Toast.LENGTH_LONG, GB.ERROR); LOG.warn("Live HR error code {} received from ring", errorCode); return; case 2: LOG.warn("Live HR error 2 (temporary error / missing data) received"); return; default: GB.toast(String.format(context.getString(R.string.smart_ring_measurement_error_unknown), errorCode), Toast.LENGTH_LONG, GB.ERROR); LOG.warn("Live HR error code {} received from ring", errorCode); return; } if (hrResponse > 0) { try (DBHandler db = GBApplication.acquireDB()) { // Build sample object and save in database ColmiHeartRateSampleProvider sampleProvider = new ColmiHeartRateSampleProvider(device, db.getDaoSession()); Long userId = DBHelper.getUser(db.getDaoSession()).getId(); Long deviceId = DBHelper.getDevice(device, db.getDaoSession()).getId(); ColmiHeartRateSample gbSample = new ColmiHeartRateSample(); gbSample.setDeviceId(deviceId); gbSample.setUserId(userId); gbSample.setTimestamp(Calendar.getInstance().getTimeInMillis()); gbSample.setHeartRate(hrResponse); sampleProvider.addSample(gbSample); // Send local intent with sample for listeners like the heart rate dialog Intent liveIntent = new Intent(DeviceService.ACTION_REALTIME_SAMPLES); liveIntent.putExtra(GBDevice.EXTRA_DEVICE, device); liveIntent.putExtra(DeviceService.EXTRA_REALTIME_SAMPLE, gbSample); LocalBroadcastManager.getInstance(context) .sendBroadcast(liveIntent); } catch (Exception e) { LOG.error("Error acquiring database for recording heart rate samples", e); } } } public static void liveActivity(byte[] value) { int steps = BLETypeConversions.toUint32(value[4], value[3], value[2], (byte) 0); int calories = BLETypeConversions.toUint32(value[7], value[6], value[5], (byte) 0) / 10; int distance = BLETypeConversions.toUint32(value[10], value[9], value[8], (byte) 0); LOG.info("Received live activity notification: {} steps, {} calories, {}m distance", steps, calories, distance); } public static void historicalActivity(GBDevice device, Context context, byte[] value) { if ((value[1] & 0xff) == 0xff) { device.unsetBusyTask(); device.sendDeviceUpdateIntent(context); LOG.info("Empty activity history, sync aborted"); } else if ((value[1] & 0xff) == 0xf0) { // initial packet, doesn't contain anything interesting } else { // Unpack timestamp and data Calendar sampleCal = Calendar.getInstance(); // The code below converts the raw hex value to a date. That seems wrong, but is correct, // because this date is for some reason transmitted as ints used as literal bytes: // A date like 2024-08-18 would be transmitted as 0x24 0x08 0x18. sampleCal.set(Calendar.YEAR, 2000 + Integer.valueOf(String.format("%02x", value[1]))); sampleCal.set(Calendar.MONTH, Integer.valueOf(String.format("%02x", value[2])) - 1); sampleCal.set(Calendar.DAY_OF_MONTH, Integer.valueOf(String.format("%02x", value[3]))); sampleCal.set(Calendar.HOUR_OF_DAY, value[4] / 4); // And the hour is transmitted as nth quarter of the day... sampleCal.set(Calendar.MINUTE, 0); sampleCal.set(Calendar.SECOND, 0); sampleCal.set(Calendar.MILLISECOND, 0); int calories = BLETypeConversions.toUint16(value[7], value[8]); int steps = BLETypeConversions.toUint16(value[9], value[10]); int distance = BLETypeConversions.toUint16(value[11], value[12]); LOG.info("Received activity sample: {} - {} calories, {} steps, {} distance", sampleCal.getTime(), calories, steps, distance); // Build sample object and save in database try (DBHandler db = GBApplication.acquireDB()) { ColmiActivitySampleProvider sampleProvider = new ColmiActivitySampleProvider(device, db.getDaoSession()); Long userId = DBHelper.getUser(db.getDaoSession()).getId(); Long deviceId = DBHelper.getDevice(device, db.getDaoSession()).getId(); ColmiActivitySample gbSample = sampleProvider.createActivitySample(); gbSample.setProvider(sampleProvider); gbSample.setDeviceId(deviceId); gbSample.setUserId(userId); gbSample.setRawKind(ActivityKind.ACTIVITY.getCode()); gbSample.setTimestamp((int) (sampleCal.getTimeInMillis() / 1000)); gbSample.setCalories(calories); gbSample.setSteps(steps); gbSample.setDistance(distance); sampleProvider.addGBActivitySample(gbSample); } catch (Exception e) { LOG.error("Error acquiring database for recording activity samples", e); } // Determine if this sync is done int currentActivityPacket = value[5]; int totalActivityPackets = value[6]; if (currentActivityPacket == totalActivityPackets - 1) { device.unsetBusyTask(); device.sendDeviceUpdateIntent(context); } } } public static void historicalStress(GBDevice device, Context context, byte[] value) { ArrayList stressSamples = new ArrayList<>(); int stressPacketNr = value[1] & 0xff; if (stressPacketNr == 0xff) { device.unsetBusyTask(); device.sendDeviceUpdateIntent(context); LOG.info("Empty stress history, sync aborted"); } else if (stressPacketNr == 0) { LOG.info("Received initial stress history response"); } else { Calendar sampleCal = Calendar.getInstance(); sampleCal.set(Calendar.SECOND, 0); sampleCal.set(Calendar.MILLISECOND, 0); int startValue = stressPacketNr == 1 ? 3 : 2; // packet 1 data starts at byte 3, others at byte 2 int minutesInPreviousPackets = 0; if (stressPacketNr > 1) { // 30 is the interval in minutes between values/measurements minutesInPreviousPackets = 12 * 30; // 12 values in packet 1 minutesInPreviousPackets += (stressPacketNr - 2) * 13 * 30; // 13 values per packet } for (int i = startValue; i < value.length - 1; i++) { if (value[i] != 0x00) { // Determine time of day int minuteOfDay = minutesInPreviousPackets + (i - startValue) * 30; sampleCal.set(Calendar.HOUR_OF_DAY, minuteOfDay / 60); sampleCal.set(Calendar.MINUTE, minuteOfDay % 60); LOG.info("Stress level is {} at {}", value[i] & 0xff, sampleCal.getTime()); // Build sample object and save in database ColmiStressSample gbSample = new ColmiStressSample(); gbSample.setTimestamp(sampleCal.getTimeInMillis()); gbSample.setStress(value[i] & 0xff); stressSamples.add(gbSample); } } if (!stressSamples.isEmpty()) { try (DBHandler db = GBApplication.acquireDB()) { ColmiStressSampleProvider sampleProvider = new ColmiStressSampleProvider(device, db.getDaoSession()); Long userId = DBHelper.getUser(db.getDaoSession()).getId(); Long deviceId = DBHelper.getDevice(device, db.getDaoSession()).getId(); for (final ColmiStressSample sample : stressSamples) { sample.setDeviceId(deviceId); sample.setUserId(userId); } LOG.info("Will persist {} stress samples", stressSamples.size()); sampleProvider.addSamples(stressSamples); } catch (Exception e) { LOG.error("Error acquiring database for recording stress samples", e); } } if (stressPacketNr == 4) { device.unsetBusyTask(); device.sendDeviceUpdateIntent(context); } } } public static void historicalSpo2(GBDevice device, byte[] value) { ArrayList spo2Samples = new ArrayList<>(); int length = BLETypeConversions.toUint16(value[2], value[3]); int index = 6; // start of data (day nr, followed by values) int spo2_days_ago = -1; while (spo2_days_ago != 0 && index - 6 < length) { spo2_days_ago = value[index]; Calendar syncingDay = Calendar.getInstance(); syncingDay.add(Calendar.DAY_OF_MONTH, 0 - spo2_days_ago); syncingDay.set(Calendar.MINUTE, 0); syncingDay.set(Calendar.SECOND, 0); syncingDay.set(Calendar.MILLISECOND, 0); index++; for (int hour=0; hour<=23; hour++) { syncingDay.set(Calendar.HOUR_OF_DAY, hour); float spo2_min = value[index]; index++; float spo2_max = value[index]; index++; if (spo2_min > 0 && spo2_max > 0) { LOG.info("Received SpO2 data from {} days ago at {}:00: min={}, max={}", spo2_days_ago, hour, spo2_min, spo2_max); ColmiSpo2Sample spo2Sample = new ColmiSpo2Sample(); spo2Sample.setTimestamp(syncingDay.getTimeInMillis()); spo2Sample.setSpo2(Math.round((spo2_min + spo2_max) / 2.0f)); spo2Samples.add(spo2Sample); } if (index - 6 >= length) { break; } } } if (!spo2Samples.isEmpty()) { try (DBHandler db = GBApplication.acquireDB()) { ColmiSpo2SampleProvider sampleProvider = new ColmiSpo2SampleProvider(device, db.getDaoSession()); Long userId = DBHelper.getUser(db.getDaoSession()).getId(); Long deviceId = DBHelper.getDevice(device, db.getDaoSession()).getId(); for (final ColmiSpo2Sample sample : spo2Samples) { sample.setDeviceId(deviceId); sample.setUserId(userId); } LOG.info("Will persist {} SpO2 samples", spo2Samples.size()); sampleProvider.addSamples(spo2Samples); } catch (Exception e) { LOG.error("Error acquiring database for recording SpO2 samples", e); } } } public static void historicalSleep(GBDevice gbDevice, Context context, byte[] value) { int packetLength = BLETypeConversions.toUint16(value[2], value[3]); if (packetLength < 2) { LOG.info("Received empty sleep data packet: {}", StringUtils.bytesToHex(value)); } else { int daysInPacket = value[6]; LOG.debug("Received sleep data packet for {} days: {}", daysInPacket, StringUtils.bytesToHex(value)); int index = 7; for (int i = 1; i <= daysInPacket; i++) { // Parse sleep session int daysAgo = value[index]; index++; int dayBytes = value[index]; index++; // sleepStart is received as "minutes after midnight" int sleepStart = BLETypeConversions.toUint16(value[index], value[index + 1]); index += 2; // sleepEnd is received as "minutes after midnight" int sleepEnd = BLETypeConversions.toUint16(value[index], value[index + 1]); index += 2; // Calculate sleep start timestamp LOG.info("Sleep session daysAgo={}, dayBytes={}, sleepStart={}, sleepEnd={}", daysAgo, dayBytes, sleepStart, sleepEnd); Calendar sessionStart = Calendar.getInstance(); sessionStart.add(Calendar.DAY_OF_MONTH, 0 - daysAgo); sessionStart.set(Calendar.HOUR_OF_DAY, 0); sessionStart.set(Calendar.MINUTE, 0); sessionStart.set(Calendar.SECOND, 0); sessionStart.set(Calendar.MILLISECOND, 0); if (sleepStart > sleepEnd) { // Sleep started a day earlier, so before midnight sessionStart.add(Calendar.MINUTE, sleepStart - 1440); } else { // Sleep started this day, so after midnight sessionStart.add(Calendar.MINUTE, sleepStart); } // Calculate sleep end timestamp Calendar sessionEnd = Calendar.getInstance(); sessionEnd.add(Calendar.DAY_OF_MONTH, 0 - daysAgo); sessionEnd.set(Calendar.HOUR_OF_DAY, 0); sessionEnd.set(Calendar.MINUTE, sleepEnd); sessionEnd.set(Calendar.SECOND, 0); sessionEnd.set(Calendar.MILLISECOND, 0); LOG.info("Sleep session starts at {} and ends at {}", sessionStart.getTime(), sessionEnd.getTime()); // Build sample object to persist final ColmiSleepSessionSample sessionSample = new ColmiSleepSessionSample(); sessionSample.setTimestamp(sessionStart.getTimeInMillis()); sessionSample.setWakeupTime(sessionEnd.getTimeInMillis()); // Handle sleep stages final List stageSamples = new ArrayList<>(); Calendar sleepStage = (Calendar) sessionStart.clone(); for (int j = 4; j < dayBytes; j += 2) { int sleepMinutes = value[index + 1]; final ColmiSleepStageSample sample = new ColmiSleepStageSample(); sample.setTimestamp(sleepStage.getTimeInMillis()); sample.setDuration(value[index + 1]); sample.setStage(value[index]); if (sleepMinutes > 0) { LOG.info("Sleep stage type={} starts at {} and lasts for {} minutes", value[index], sleepStage.getTime(), sleepMinutes); if (sleepStage.getTimeInMillis() + sleepMinutes * 60 * 1000 > sessionEnd.getTimeInMillis()) { LOG.warn("Warning: sleep stage exceeds end of sleep session, received data may be corrupt"); } stageSamples.add(sample); sleepStage.add(Calendar.MINUTE, sleepMinutes); } else { LOG.info("Ignoring sleep stage type={} starts at {} and lasts for {} minutes", value[index], sleepStage.getTime(), sleepMinutes); } // Prepare for next sample index += 2; } // Persist sleep session try (DBHandler handler = GBApplication.acquireDB()) { final DaoSession session = handler.getDaoSession(); final Device device = DBHelper.getDevice(gbDevice, session); final User user = DBHelper.getUser(session); final ColmiSleepSessionSampleProvider sampleProvider = new ColmiSleepSessionSampleProvider(gbDevice, session); sessionSample.setDevice(device); sessionSample.setUser(user); LOG.debug("Will persist 1 sleep session sample from {} to {}", sessionSample.getTimestamp(), sessionSample.getWakeupTime()); sampleProvider.addSample(sessionSample); } catch (final Exception e) { GB.toast(context, "Error saving sleep session sample", Toast.LENGTH_LONG, GB.ERROR, e); } // Persist sleep stages try (DBHandler handler = GBApplication.acquireDB()) { final DaoSession session = handler.getDaoSession(); final Device device = DBHelper.getDevice(gbDevice, session); final User user = DBHelper.getUser(session); final ColmiSleepStageSampleProvider sampleProvider = new ColmiSleepStageSampleProvider(gbDevice, session); for (final ColmiSleepStageSample sample : stageSamples) { sample.setDevice(device); sample.setUser(user); } LOG.debug("Will persist {} sleep stage samples", stageSamples.size()); sampleProvider.addSamples(stageSamples); } catch (final Exception e) { GB.toast(context, "Error saving sleep stage samples", Toast.LENGTH_LONG, GB.ERROR, e); } } } } public static void historicalHRV(GBDevice device, Context context, byte[] value, int daysAgo) { LOG.info("Received HRV history sync packet: {}", StringUtils.bytesToHex(value)); int hrvPacketNr = value[1] & 0xff; if (hrvPacketNr == 0xff) { LOG.info("Empty HRV history, sync aborted"); device.unsetBusyTask(); device.sendDeviceUpdateIntent(context); } else if (hrvPacketNr == 0) { int packetsTotalNr = value[2]; LOG.info("HRV history packet {} out of total {}", hrvPacketNr, packetsTotalNr); } else { LOG.info("HRV history packet {}", hrvPacketNr); Calendar sampleCal = Calendar.getInstance(); if (daysAgo != 0) { sampleCal.add(Calendar.DAY_OF_MONTH, 0 - daysAgo); sampleCal.set(Calendar.HOUR_OF_DAY, 0); sampleCal.set(Calendar.MINUTE, 0); } sampleCal.set(Calendar.SECOND, 0); sampleCal.set(Calendar.MILLISECOND, 0); int startValue = hrvPacketNr == 1 ? 3 : 2; // packet 1 contains something in byte 2 int minutesInPreviousPackets = 0; if (hrvPacketNr > 1) { minutesInPreviousPackets = 12 * 30; // packet 1 minutesInPreviousPackets += (hrvPacketNr - 2) * 13 * 30; } for (int i = startValue; i < value.length - 1; i++) { if (value[i] != 0x00) { // Determine time of day int minuteOfDay = minutesInPreviousPackets + (i - startValue) * 30; sampleCal.set(Calendar.HOUR_OF_DAY, minuteOfDay / 60); sampleCal.set(Calendar.MINUTE, minuteOfDay % 60); LOG.info("Value {} is {} ms, time of day is {}", i, value[i] & 0xff, sampleCal.getTime()); // Build sample object and save in database try (DBHandler db = GBApplication.acquireDB()) { ColmiHrvValueSampleProvider sampleProvider = new ColmiHrvValueSampleProvider(device, db.getDaoSession()); Long userId = DBHelper.getUser(db.getDaoSession()).getId(); Long deviceId = DBHelper.getDevice(device, db.getDaoSession()).getId(); ColmiHrvValueSample gbSample = new ColmiHrvValueSample(); gbSample.setDeviceId(deviceId); gbSample.setUserId(userId); gbSample.setTimestamp(sampleCal.getTimeInMillis()); gbSample.setValue(value[i] & 0xff); sampleProvider.addSample(gbSample); } catch (Exception e) { LOG.error("Error acquiring database for recording HRV samples", e); } } } if (hrvPacketNr == 4) { device.unsetBusyTask(); device.sendDeviceUpdateIntent(context); } } } }