/* Copyright (C) 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.cmfwatchpro; import android.content.Context; import android.widget.Toast; import androidx.annotation.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.List; import de.greenrobot.dao.query.QueryBuilder; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfActivitySampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfHeartRateSampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfSleepSessionSampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfSleepStageSampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfSpo2SampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfStressSampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfWorkoutGpsSampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.workout.CmfWorkoutSummaryParser; import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary; import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao; import nodomain.freeyourgadget.gadgetbridge.entities.CmfActivitySample; import nodomain.freeyourgadget.gadgetbridge.entities.CmfHeartRateSample; import nodomain.freeyourgadget.gadgetbridge.entities.CmfSleepSessionSample; import nodomain.freeyourgadget.gadgetbridge.entities.CmfSleepStageSample; import nodomain.freeyourgadget.gadgetbridge.entities.CmfSpo2Sample; import nodomain.freeyourgadget.gadgetbridge.entities.CmfStressSample; import nodomain.freeyourgadget.gadgetbridge.entities.CmfWorkoutGpsSample; import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; import nodomain.freeyourgadget.gadgetbridge.entities.Device; import nodomain.freeyourgadget.gadgetbridge.entities.User; import nodomain.freeyourgadget.gadgetbridge.export.ActivityTrackExporter; import nodomain.freeyourgadget.gadgetbridge.export.GPXExporter; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint; import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack; import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate; import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; import nodomain.freeyourgadget.gadgetbridge.util.GB; public class CmfActivitySync { private static final Logger LOG = LoggerFactory.getLogger(CmfActivitySync.class); private final CmfWatchProSupport mSupport; private final List activitiesWithGps = new ArrayList<>(); protected CmfActivitySync(final CmfWatchProSupport support) { this.mSupport = support; } protected boolean onCommand(final CmfCommand cmd, final byte[] payload) { switch (cmd) { case ACTIVITY_FETCH_ACK_1: handleActivityFetchAck1(payload); return true; case ACTIVITY_FETCH_ACK_2: handleActivityFetchAck2(payload); return true; case ACTIVITY_DATA: handleActivityData(payload); return true; case HEART_RATE_MANUAL_AUTO: case HEART_RATE_WORKOUT: handleHeartRate(payload); return true; case HEART_RATE_RESTING: handleHeartRateResting(payload); return true; case SLEEP_DATA: handleSleepData(payload); return true; case STRESS: handleStress(payload); return true; case SPO2: handleSpo2(payload); return true; case WORKOUT_SUMMARY: handleWorkoutSummary(payload); return true; case WORKOUT_GPS: handleWorkoutGps(payload); return true; } return false; } private void handleActivityFetchAck1(final byte[] payload) { switch (payload[0]) { case 0x01: LOG.debug("Got activity fetch ack 1, starting step 2"); GB.updateTransferNotification(getContext().getString(R.string.busy_task_fetch_activity_data), "", true, 0, getContext()); getDevice().setBusyTask(getContext().getString(R.string.busy_task_fetch_activity_data)); mSupport.sendCommand("fetch recorded data step 2", CmfCommand.ACTIVITY_FETCH_2, CmfWatchProSupport.A5); break; case 0x02: LOG.debug("Got activity fetch finish"); // Process activities with GPS before unsetting device as busy processActivitiesWithGps(); break; default: LOG.warn("Unknown activity fetch ack code {}", payload[0]); return; } getDevice().sendDeviceUpdateIntent(getContext()); } private static void handleActivityFetchAck2(final byte[] payload) { final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN); final int activityTs = buf.getInt(); final byte[] activityFlags = new byte[4]; // TODO what do they mean? buf.order(ByteOrder.BIG_ENDIAN).get(activityFlags); LOG.debug("Getting activity since {}, flags={}", activityTs, GB.hexdump(activityFlags)); } private void handleActivityData(final byte[] payload) { if (payload.length % 32 != 0) { LOG.error("Activity data payload size {} not divisible by 32", payload.length); return; } LOG.debug("Got {} activity samples", payload.length / 32); final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN); final List samples = new ArrayList<>(); while (buf.remaining() > 0) { final CmfActivitySample sample = new CmfActivitySample(); sample.setTimestamp(buf.getInt()); sample.setSteps(buf.getInt()); sample.setDistance(buf.getInt()); sample.setCalories(buf.getInt()); final byte[] unk = new byte[16]; buf.get(unk); samples.add(sample); } try (DBHandler handler = GBApplication.acquireDB()) { final DaoSession session = handler.getDaoSession(); final Device device = DBHelper.getDevice(getDevice(), session); final User user = DBHelper.getUser(session); final CmfActivitySampleProvider sampleProvider = new CmfActivitySampleProvider(getDevice(), session); for (final CmfActivitySample sample : samples) { sample.setDevice(device); sample.setUser(user); sample.setProvider(sampleProvider); } LOG.debug("Will persist {} activity samples", samples.size()); sampleProvider.addGBActivitySamples(samples.toArray(new CmfActivitySample[0])); } catch (final Exception e) { GB.toast(getContext(), "Error saving activity samples", Toast.LENGTH_LONG, GB.ERROR, e); } } private void handleHeartRate(final byte[] payload) { if (payload.length % 8 != 0) { LOG.error("Heart rate payload size {} not divisible by 8", payload.length); return; } LOG.debug("Got {} heart rate samples", payload.length / 8); final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN); final List samples = new ArrayList<>(); while (buf.remaining() > 0) { final CmfHeartRateSample sample = new CmfHeartRateSample(); sample.setTimestamp(buf.getInt() * 1000L); sample.setHeartRate(buf.getInt()); samples.add(sample); } try (DBHandler handler = GBApplication.acquireDB()) { final DaoSession session = handler.getDaoSession(); final Device device = DBHelper.getDevice(getDevice(), session); final User user = DBHelper.getUser(session); final CmfHeartRateSampleProvider sampleProvider = new CmfHeartRateSampleProvider(getDevice(), session); for (final CmfHeartRateSample sample : samples) { sample.setDevice(device); sample.setUser(user); } LOG.debug("Will persist {} heart rate samples", samples.size()); sampleProvider.addSamples(samples); } catch (final Exception e) { GB.toast(getContext(), "Error saving heart rate samples", Toast.LENGTH_LONG, GB.ERROR, e); } } private static void handleHeartRateResting(final byte[] payload) { // TODO persist resting HR samples; LOG.warn("Persisting resting HR samples is not implemented"); } private void handleSleepData(final byte[] payload) { final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN); LOG.debug("Got sleep data samples"); final int sessionTimestamp = buf.getInt(); final int wakeupTime = buf.getInt(); final byte[] metadata = new byte[10]; buf.get(metadata); final CmfSleepSessionSample sessionSample = new CmfSleepSessionSample(); sessionSample.setTimestamp(sessionTimestamp * 1000L); sessionSample.setWakeupTime(wakeupTime * 1000L); sessionSample.setMetadata(metadata); final List stageSamples = new ArrayList<>(); while (buf.remaining() > 0) { final CmfSleepStageSample sample = new CmfSleepStageSample(); sample.setTimestamp(buf.getInt() * 1000L); sample.setDuration(buf.getShort()); sample.setStage(buf.getShort()); stageSamples.add(sample); } try (DBHandler handler = GBApplication.acquireDB()) { final DaoSession session = handler.getDaoSession(); final Device device = DBHelper.getDevice(getDevice(), session); final User user = DBHelper.getUser(session); final CmfSleepSessionSampleProvider sampleProvider = new CmfSleepSessionSampleProvider(getDevice(), 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(getContext(), "Error saving sleep session sample", Toast.LENGTH_LONG, GB.ERROR, e); } try (DBHandler handler = GBApplication.acquireDB()) { final DaoSession session = handler.getDaoSession(); final Device device = DBHelper.getDevice(getDevice(), session); final User user = DBHelper.getUser(session); final CmfSleepStageSampleProvider sampleProvider = new CmfSleepStageSampleProvider(getDevice(), session); for (final CmfSleepStageSample 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(getContext(), "Error saving sleep samples", Toast.LENGTH_LONG, GB.ERROR, e); } } private void handleStress(final byte[] payload) { if (payload.length % 8 != 0) { LOG.error("Stress payload size {} not divisible by 8", payload.length); return; } LOG.debug("Got {} stress samples", payload.length / 8); final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN); final List samples = new ArrayList<>(); while (buf.remaining() > 0) { final CmfStressSample sample = new CmfStressSample(); sample.setTimestamp(buf.getInt() * 1000L); sample.setStress(buf.getInt()); samples.add(sample); } try (DBHandler handler = GBApplication.acquireDB()) { final DaoSession session = handler.getDaoSession(); final Device device = DBHelper.getDevice(getDevice(), session); final User user = DBHelper.getUser(session); final CmfStressSampleProvider sampleProvider = new CmfStressSampleProvider(getDevice(), session); for (final CmfStressSample sample : samples) { sample.setDevice(device); sample.setUser(user); } LOG.debug("Will persist {} stress samples", samples.size()); sampleProvider.addSamples(samples); } catch (final Exception e) { GB.toast(getContext(), "Error saving stress samples", Toast.LENGTH_LONG, GB.ERROR, e); } } private void handleSpo2(final byte[] payload) { if (payload.length % 8 != 0) { LOG.error("Spo2 payload size {} not divisible by 8", payload.length); return; } LOG.debug("Got {} spo2 samples", payload.length / 8); final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN); final List samples = new ArrayList<>(); while (buf.remaining() > 0) { final CmfSpo2Sample sample = new CmfSpo2Sample(); sample.setTimestamp(buf.getInt() * 1000L); sample.setSpo2(buf.getInt()); samples.add(sample); } try (DBHandler handler = GBApplication.acquireDB()) { final DaoSession session = handler.getDaoSession(); final Device device = DBHelper.getDevice(getDevice(), session); final User user = DBHelper.getUser(session); final CmfSpo2SampleProvider sampleProvider = new CmfSpo2SampleProvider(getDevice(), session); for (final CmfSpo2Sample sample : samples) { sample.setDevice(device); sample.setUser(user); } LOG.debug("Will persist {} spo2 samples", samples.size()); sampleProvider.addSamples(samples); } catch (final Exception e) { GB.toast(getContext(), "Error saving spo2 samples", Toast.LENGTH_LONG, GB.ERROR, e); } } private void handleWorkoutSummary(final byte[] payload) { final int bytesPerWorkout; if (payload.length % 32 == 0) { bytesPerWorkout = 32; } else if (payload.length % 54 == 0) { bytesPerWorkout = 54; } else { LOG.error("Workout summary payload size {} not divisible by 32 or 54", payload.length); return; } LOG.debug("Got {} workout summary samples", payload.length / bytesPerWorkout); final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN); final CmfWorkoutSummaryParser summaryParser = new CmfWorkoutSummaryParser(getDevice()); while (buf.remaining() > 0) { final byte[] summaryBytes = new byte[bytesPerWorkout]; buf.get(summaryBytes); BaseActivitySummary summary = new BaseActivitySummary(); summary.setRawSummaryData(summaryBytes); summary.setActivityKind(ActivityKind.UNKNOWN.getCode()); try { summary = summaryParser.parseBinaryData(summary, true); } catch (final Exception e) { LOG.error("Failed to parse workout summary", e); GB.toast(getContext(), "Failed to parse workout summary", Toast.LENGTH_LONG, GB.ERROR, e); return; } if (summary == null) { LOG.error("Workout summary is null"); return; } summary.setSummaryData(null); // remove json before saving to database try (DBHandler dbHandler = GBApplication.acquireDB()) { final DaoSession session = dbHandler.getDaoSession(); final Device device = DBHelper.getDevice(getDevice(), session); final User user = DBHelper.getUser(session); summary.setDevice(device); summary.setUser(user); LOG.debug("Persisting workout summary for {}", summary.getStartTime()); session.getBaseActivitySummaryDao().insertOrReplace(summary); } catch (final Exception e) { GB.toast(getContext(), "Error saving activity summary", Toast.LENGTH_LONG, GB.ERROR, e); return; } // FIXME: This should be set by CmfWorkoutSummaryParser if (summaryBytes[30] == 1) { activitiesWithGps.add(summary); } } } private void handleWorkoutGps(final byte[] payload) { if (payload.length % 12 != 0) { LOG.error("Workout gps payload size {} not divisible by 12", payload.length); return; } LOG.debug("Got {} workout gps samples", payload.length / 12); final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN); final List samples = new ArrayList<>(); while (buf.remaining() > 0) { final CmfWorkoutGpsSample sample = new CmfWorkoutGpsSample(); sample.setTimestamp(buf.getInt() * 1000L); sample.setLongitude(buf.getInt()); sample.setLatitude(buf.getInt()); samples.add(sample); } try (DBHandler handler = GBApplication.acquireDB()) { final DaoSession session = handler.getDaoSession(); final Device device = DBHelper.getDevice(getDevice(), session); final User user = DBHelper.getUser(session); final CmfWorkoutGpsSampleProvider sampleProvider = new CmfWorkoutGpsSampleProvider(getDevice(), session); for (final CmfWorkoutGpsSample sample : samples) { sample.setDevice(device); sample.setUser(user); } LOG.debug("Will persist {} workout gps samples", samples.size()); sampleProvider.addSamples(samples); } catch (final Exception e) { GB.toast(getContext(), "Error saving workout gps samples", Toast.LENGTH_LONG, GB.ERROR, e); } } private void processActivitiesWithGps() { LOG.debug("There are {} activities with gps to process", activitiesWithGps.size()); for (final BaseActivitySummary summary : activitiesWithGps) { processGps(summary); } activitiesWithGps.clear(); getDevice().unsetBusyTask(); GB.signalActivityDataFinish(getDevice()); GB.updateTransferNotification(null, "", false, 100, getContext()); } private void processGps(final BaseActivitySummary summary) { final ActivityTrack activityTrack = buildActivityTrack(summary); if (activityTrack == null) { return; } // Save the gpx file final File gpxFile = exportGpx(summary, activityTrack); if (gpxFile == null) { return; } // Update the summary in the db with the gpx path try (DBHandler dbHandler = GBApplication.acquireDB()) { final DaoSession session = dbHandler.getDaoSession(); final Device device = DBHelper.getDevice(mSupport.getDevice(), session); final User user = DBHelper.getUser(session); final BaseActivitySummaryDao summaryDao = session.getBaseActivitySummaryDao(); final QueryBuilder qb = summaryDao.queryBuilder(); qb.where(BaseActivitySummaryDao.Properties.StartTime.eq(summary.getStartTime())); qb.where(BaseActivitySummaryDao.Properties.DeviceId.eq(device.getId())); qb.where(BaseActivitySummaryDao.Properties.UserId.eq(user.getId())); final List summaries = qb.build().list(); if (summaries.isEmpty()) { LOG.warn("Failed to find existing summary in db - this should never happen"); return; } if (summaries.size() > 1) { LOG.warn("Found multiple summaries in db - this should never happen"); } final BaseActivitySummary summaryToUpdate = summaries.get(0); summaryToUpdate.setGpxTrack(gpxFile.getAbsolutePath()); session.getBaseActivitySummaryDao().insertOrReplace(summaryToUpdate); } catch (final Exception e) { LOG.error("Failed to update summary with gpx path", e); } } @Nullable private File exportGpx(final BaseActivitySummary summary, final ActivityTrack activityTrack) { final GPXExporter exporter = new GPXExporter(); final String gpxFileName = FileUtils.makeValidFileName("gadgetbridge-" + DateTimeUtils.formatIso8601(summary.getStartTime()) + ".gpx"); final File gpxTargetFile; try { gpxTargetFile = new File(FileUtils.getExternalFilesDir(), gpxFileName); } catch (final IOException e) { LOG.error("Failed to get external files dir", e); return null; } try { exporter.performExport(activityTrack, gpxTargetFile); } catch (final ActivityTrackExporter.GPXTrackEmptyException e) { LOG.warn("Gpx is empty"); return null; } catch (IOException e) { LOG.error("Failed to write gpx", e); return null; } return gpxTargetFile; } @Nullable private ActivityTrack buildActivityTrack(final BaseActivitySummary summary) { final ActivityTrack track = new ActivityTrack(); track.setUser(summary.getUser()); track.setDevice(summary.getDevice()); track.setName(createActivityName(summary)); final List gpsSamples; final List hrSamples; try (DBHandler handler = GBApplication.acquireDB()) { final DaoSession session = handler.getDaoSession(); final CmfWorkoutGpsSampleProvider gpsSampleProvider = new CmfWorkoutGpsSampleProvider(getDevice(), session); gpsSamples = gpsSampleProvider.getAllSamples(summary.getStartTime().getTime(), summary.getEndTime().getTime()); final CmfHeartRateSampleProvider hrSampleProvider = new CmfHeartRateSampleProvider(getDevice(), session); hrSamples = new ArrayList<>(hrSampleProvider.getAllSamples(summary.getStartTime().getTime(), summary.getEndTime().getTime())); } catch (final Exception e) { LOG.error("Error while building activity track", e); return null; } Collections.sort(hrSamples, (a, b) -> Long.compare(a.getTimestamp(), b.getTimestamp())); for (final CmfWorkoutGpsSample gpsSample : gpsSamples) { final ActivityPoint ap = new ActivityPoint(new Date(gpsSample.getTimestamp())); final GPSCoordinate coordinate = new GPSCoordinate( gpsSample.getLongitude() / 10000000d, gpsSample.getLatitude() / 10000000d ); ap.setLocation(coordinate); final CmfHeartRateSample hrSample = findNearestSample(hrSamples, gpsSample.getTimestamp()); if (hrSample != null) { ap.setHeartRate(hrSample.getHeartRate()); } track.addTrackPoint(ap); } return track; } @Nullable private CmfHeartRateSample findNearestSample(final List samples, final long timestamp) { if (samples.isEmpty()) { return null; } if (timestamp < samples.get(0).getTimestamp()) { return samples.get(0); } if (timestamp > samples.get(samples.size() - 1).getTimestamp()) { return samples.get(samples.size() - 1); } int start = 0; int end = samples.size() - 1; while (start <= end) { final int mid = (start + end) / 2; if (timestamp < samples.get(mid).getTimestamp()) { end = mid - 1; } else if (timestamp > samples.get(mid).getTimestamp()) { start = mid + 1; } else { return samples.get(mid); } } // FIXME return null if too far? if (samples.get(start).getTimestamp() - timestamp < timestamp - samples.get(end).getTimestamp()) { return samples.get(start); } return samples.get(end); } protected static String createActivityName(final BaseActivitySummary summary) { String name = summary.getName(); String nameText = ""; Long id = summary.getId(); if (name != null) { nameText = name + " - "; } return nameText + id; } private Context getContext() { return mSupport.getContext(); } private GBDevice getDevice() { return mSupport.getDevice(); } }