1
0
mirror of https://codeberg.org/Freeyourgadget/Gadgetbridge synced 2024-06-21 12:30:23 +02:00
Gadgetbridge/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/hplus/HPlusHandlerThread.java
2021-01-10 23:38:13 +01:00

650 lines
24 KiB
Java

/* Copyright (C) 2017-2021 Daniele Gobbetti, João Paulo Barraca
Lesur Frederic
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 <http://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.hplus;
import android.content.Context;
import android.content.Intent;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.Comparator;
import java.util.GregorianCalendar;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.GBException;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
import nodomain.freeyourgadget.gadgetbridge.devices.hplus.HPlusConstants;
import nodomain.freeyourgadget.gadgetbridge.devices.hplus.HPlusCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.hplus.HPlusHealthSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.HPlusHealthActivityOverlay;
import nodomain.freeyourgadget.gadgetbridge.entities.HPlusHealthActivityOverlayDao;
import nodomain.freeyourgadget.gadgetbridge.entities.HPlusHealthActivitySample;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.serial.GBDeviceIoThread;
class HPlusHandlerThread extends GBDeviceIoThread {
private static final Logger LOG = LoggerFactory.getLogger(HPlusHandlerThread.class);
private final Object waitObject = new Object();
List<HPlusDataRecordDaySlot> mDaySlotRecords = new ArrayList<>();
private int CURRENT_DAY_SYNC_PERIOD = 24 * 60 * 60 * 365; //Never
private int CURRENT_DAY_SYNC_RETRY_PERIOD = 10;
private int SLEEP_SYNC_PERIOD = 12 * 60 * 60;
private int SLEEP_SYNC_RETRY_PERIOD = 30;
private int DAY_SUMMARY_SYNC_PERIOD = 24 * 60 * 60;
private int DAY_SUMMARY_SYNC_RETRY_PERIOD = 30;
private int HELLO_PERIOD = 60 * 2;
private boolean mQuit = false;
private HPlusSupport mHPlusSupport;
private int mLastSlotReceived = -1;
private int mLastSlotRequested = 0;
private Calendar mLastSleepDayReceived = GregorianCalendar.getInstance();
private Calendar mGetDaySlotsTime = GregorianCalendar.getInstance();
private Calendar mGetSleepTime = GregorianCalendar.getInstance();
private Calendar mGetDaySummaryTime = GregorianCalendar.getInstance();
private Calendar mHelloTime = GregorianCalendar.getInstance();
private boolean mSlotsInitialSync = true;
private HPlusDataRecordRealtime prevRealTimeRecord = null;
private HPlusDataRecordDaySlot mCurrentDaySlot = null;
public HPlusHandlerThread(GBDevice gbDevice, Context context, HPlusSupport hplusSupport) {
super(gbDevice, context);
LOG.info("Initializing HPlus Handler Thread");
mQuit = false;
mHPlusSupport = hplusSupport;
}
@Override
public void run() {
mQuit = false;
sync();
long waitTime = 0;
while (!mQuit) {
if (waitTime > 0) {
synchronized (waitObject) {
try {
waitObject.wait(waitTime);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
if (mQuit) {
break;
}
if (gbDevice.getState() == GBDevice.State.NOT_CONNECTED) {
quit();
}
Calendar now = GregorianCalendar.getInstance();
if (now.compareTo(mGetDaySlotsTime) > 0) {
requestNextDaySlots();
}
if (now.compareTo(mGetSleepTime) > 0) {
requestNextSleepData();
}
if (now.compareTo(mGetDaySummaryTime) > 0) {
requestDaySummaryData();
}
if (now.compareTo(mHelloTime) > 0) {
sendHello();
}
now = GregorianCalendar.getInstance();
waitTime = Math.min(mGetDaySummaryTime.getTimeInMillis(), Math.min(mGetDaySlotsTime.getTimeInMillis(), Math.min(mHelloTime.getTimeInMillis(), mGetSleepTime.getTimeInMillis()))) - now.getTimeInMillis();
}
}
@Override
public void quit() {
LOG.info("HPlus: Quit Handler Thread");
mQuit = true;
synchronized (waitObject) {
waitObject.notify();
}
}
public void sync() {
LOG.info("HPlus: Starting data synchronization");
mGetSleepTime.setTimeInMillis(0);
mGetDaySlotsTime.setTimeInMillis(0);
mGetDaySummaryTime.setTimeInMillis(0);
mLastSleepDayReceived.setTimeInMillis(0);
mHelloTime = GregorianCalendar.getInstance();
mHelloTime.add(Calendar.SECOND, HELLO_PERIOD);
mSlotsInitialSync = true;
mLastSlotReceived = -1;
mLastSlotRequested = 0;
mCurrentDaySlot = null;
mDaySlotRecords.clear();
try {
if (!mHPlusSupport.isConnected())
mHPlusSupport.connect();
TransactionBuilder builder = new TransactionBuilder("startSyncDayStats");
builder.write(mHPlusSupport.ctrlCharacteristic, new byte[]{HPlusConstants.CMD_GET_DEVICE_ID});
builder.write(mHPlusSupport.ctrlCharacteristic, new byte[]{HPlusConstants.CMD_GET_VERSION});
builder.write(mHPlusSupport.ctrlCharacteristic, new byte[]{HPlusConstants.CMD_GET_CURR_DATA});
mHPlusSupport.performConnected(builder.getTransaction());
} catch (Exception e) {
LOG.warn("HPlus: Synchronization exception: " + e);
}
synchronized (waitObject) {
waitObject.notify();
}
}
public void sendHello() {
try {
TransactionBuilder builder = new TransactionBuilder("hello");
builder.write(mHPlusSupport.ctrlCharacteristic, HPlusConstants.CMD_ACTION_HELLO);
mHPlusSupport.performConnected(builder.getTransaction());
} catch (Exception e) {
}
mHelloTime = GregorianCalendar.getInstance();
mHelloTime.add(Calendar.SECOND, HELLO_PERIOD);
synchronized (waitObject) {
waitObject.notify();
}
}
/**
* Process a message containing information regarding a day slot
* A slot summarizes 10 minutes of data
*
* @param data the message from the device
* @return boolean indicating success or fail
*/
public boolean processIncomingDaySlotData(byte[] data, int age) {
HPlusDataRecordDaySlot record;
try {
record = new HPlusDataRecordDaySlot(data, age);
} catch (IllegalArgumentException e) {
LOG.info((e.getMessage()));
return false;
}
Calendar now = GregorianCalendar.getInstance();
int nowSlot = now.get(Calendar.HOUR_OF_DAY) * 6 + (now.get(Calendar.MINUTE) / 10);
if (record.slot == nowSlot) {
if (mCurrentDaySlot != null && mCurrentDaySlot != record) {
mCurrentDaySlot.accumulate(record);
mDaySlotRecords.add(mCurrentDaySlot);
mCurrentDaySlot = null;
} else {
//Store it to a temp variable as this is an intermediate value
mCurrentDaySlot = record;
if (!mSlotsInitialSync)
return true;
}
}
if (mSlotsInitialSync) {
//If the slot is in the future, actually it is from the previous day
//Subtract a day of seconds
if (record.slot > nowSlot) {
record.timestamp -= 3600 * 24;
}
if (record.slot == mLastSlotReceived + 1) {
mLastSlotReceived = record.slot;
}
//Ignore the current slot as it is incomplete
if (record.slot != nowSlot)
mDaySlotRecords.add(record);
//Still fetching ring buffer. Request the next slots
if (record.slot == mLastSlotRequested) {
mGetDaySlotsTime.clear();
synchronized (waitObject) {
waitObject.notify();
}
}
//Keep buffering
if (record.slot != 143)
return true;
} else {
mGetDaySlotsTime = GregorianCalendar.getInstance();
mGetDaySlotsTime.add(Calendar.DAY_OF_MONTH, 1);
}
if (mDaySlotRecords.size() > 0) {
//Sort the samples
Collections.sort(mDaySlotRecords, new Comparator<HPlusDataRecordDaySlot>() {
public int compare(HPlusDataRecordDaySlot one, HPlusDataRecordDaySlot other) {
return one.timestamp - other.timestamp;
}
});
List<Integer> notWornSlots = new ArrayList<>();
try (DBHandler dbHandler = GBApplication.acquireDB()) {
HPlusHealthSampleProvider provider = new HPlusHealthSampleProvider(getDevice(), dbHandler.getDaoSession());
List<HPlusHealthActivitySample> samples = new ArrayList<>();
for (HPlusDataRecordDaySlot storedRecord : mDaySlotRecords) {
//Invalid records (no data) will be ignored
if (!storedRecord.isValid())
continue;
HPlusHealthActivitySample sample = createSample(dbHandler, storedRecord.timestamp);
sample.setRawHPlusHealthData(storedRecord.getRawData());
sample.setSteps(storedRecord.steps);
sample.setRawIntensity(storedRecord.intensity);
sample.setHeartRate(storedRecord.heartRate);
sample.setRawKind(storedRecord.type);
sample.setProvider(provider);
samples.add(sample);
if (HPlusCoordinator.getAllDayHR(gbDevice.getAddress()) == HPlusConstants.ARG_HEARTRATE_ALLDAY_ON && storedRecord.heartRate == ActivitySample.NOT_MEASURED && storedRecord.steps <= 0) {
notWornSlots.add(sample.getTimestamp());
notWornSlots.add(sample.getTimestamp() + 10 * 60);
}
}
provider.getSampleDao().insertOrReplaceInTx(samples);
mDaySlotRecords.clear();
//Create an overlay with unused slots
if (notWornSlots.size() > 0) {
DaoSession session = dbHandler.getDaoSession();
Long userId = DBHelper.getUser(session).getId();
Long deviceId = DBHelper.getDevice(getDevice(), session).getId();
HPlusHealthActivityOverlayDao overlayDao = session.getHPlusHealthActivityOverlayDao();
List<HPlusHealthActivityOverlay> overlayList = new ArrayList<>();
int firstSlotTimestamp = notWornSlots.get(0);
int lastSlotTimestamp = notWornSlots.get(0);
int i = 1;
for (Integer timestamp : notWornSlots) {
//If it is the last of the samples or of the interruption period
if (timestamp - lastSlotTimestamp > 10 * 60) {
overlayList.add(new HPlusHealthActivityOverlay(firstSlotTimestamp, lastSlotTimestamp, ActivityKind.TYPE_NOT_WORN, deviceId, userId, null));
firstSlotTimestamp = timestamp;
}
lastSlotTimestamp = timestamp;
}
if (firstSlotTimestamp != lastSlotTimestamp)
overlayList.add(new HPlusHealthActivityOverlay(firstSlotTimestamp, lastSlotTimestamp, ActivityKind.TYPE_NOT_WORN, deviceId, userId, null));
overlayDao.insertOrReplaceInTx(overlayList);
}
} catch (GBException ex) {
LOG.info((ex.getMessage()));
} catch (Exception ex) {
LOG.info(ex.getMessage());
}
}
return true;
}
/**
* Process sleep data from the device
* Devices send a single sleep message for each sleep period
* This message contains the duration of the sub-intervals (rem, deep, etc...)
*
* @param data the message from the device
* @return boolean indicating success or fail
*/
public boolean processIncomingSleepData(byte[] data) {
HPlusDataRecordSleep record;
try {
record = new HPlusDataRecordSleep(data);
} catch (IllegalArgumentException e) {
LOG.info((e.getMessage()));
return false;
}
mLastSleepDayReceived.setTimeInMillis(record.bedTimeStart * 1000L);
try (DBHandler dbHandler = GBApplication.acquireDB()) {
DaoSession session = dbHandler.getDaoSession();
Long userId = DBHelper.getUser(session).getId();
Long deviceId = DBHelper.getDevice(getDevice(), session).getId();
HPlusHealthActivityOverlayDao overlayDao = session.getHPlusHealthActivityOverlayDao();
HPlusHealthSampleProvider provider = new HPlusHealthSampleProvider(getDevice(), dbHandler.getDaoSession());
//Get the individual Sleep overlays and insert them
List<HPlusHealthActivityOverlay> overlayList = new ArrayList<>();
List<HPlusDataRecord.RecordInterval> intervals = record.getIntervals();
for (HPlusDataRecord.RecordInterval interval : intervals) {
overlayList.add(new HPlusHealthActivityOverlay(interval.timestampFrom, interval.timestampTo, interval.activityKind, deviceId, userId, null));
}
overlayDao.insertOrReplaceInTx(overlayList);
//Store the data
HPlusHealthActivitySample sample = createSample(dbHandler, record.timestamp);
sample.setRawHPlusHealthData(record.getRawData());
sample.setRawKind(record.activityKind);
sample.setProvider(provider);
provider.addGBActivitySample(sample);
} catch (Exception ex) {
LOG.info(ex.getMessage());
}
mGetSleepTime = GregorianCalendar.getInstance();
mGetSleepTime.add(GregorianCalendar.SECOND, SLEEP_SYNC_PERIOD);
return true;
}
/**
* Process a message containing real time information
*
* @param data the message from the device
* @return boolean indicating success or fail
*/
public boolean processRealtimeStats(byte[] data, int age) {
HPlusDataRecordRealtime record;
try {
record = new HPlusDataRecordRealtime(data, age);
} catch (IllegalArgumentException e) {
LOG.info((e.getMessage()));
return false;
}
//Skip duplicated messages as the device seems to send the same record multiple times
//This can be used to detect the user is moving (not sleeping)
if (prevRealTimeRecord != null && record.same(prevRealTimeRecord))
return true;
prevRealTimeRecord = record;
getDevice().setBatteryLevel(record.battery);
try (DBHandler dbHandler = GBApplication.acquireDB()) {
HPlusHealthSampleProvider provider = new HPlusHealthSampleProvider(getDevice(), dbHandler.getDaoSession());
HPlusHealthActivitySample sample = createSample(dbHandler, record.timestamp);
sample.setRawKind(record.type);
sample.setRawIntensity(record.intensity);
sample.setHeartRate(record.heartRate);
sample.setDistance(record.distance);
sample.setCalories(record.calories);
sample.setSteps(record.steps);
sample.setRawHPlusHealthData(record.getRawData());
sample.setProvider(provider);
provider.addGBActivitySample(sample);
sample.setSteps(sample.getSteps() - prevRealTimeRecord.steps);
Intent intent = new Intent(DeviceService.ACTION_REALTIME_SAMPLES)
.putExtra(DeviceService.EXTRA_REALTIME_SAMPLE, sample)
.putExtra(DeviceService.EXTRA_TIMESTAMP, System.currentTimeMillis());
LocalBroadcastManager.getInstance(getContext()).sendBroadcast(intent);
//TODO: Handle Active Time. With Overlay?
} catch (GBException ex) {
LOG.info((ex.getMessage()));
} catch (Exception ex) {
LOG.info(ex.getMessage());
}
return true;
}
/**
* Process a day summary message
* This message includes aggregates regarding an entire day
*
* @param data the message from the device
* @return boolean indicating success or fail
*/
public boolean processDaySummary(byte[] data) {
HPlusDataRecordDaySummary record;
try {
record = new HPlusDataRecordDaySummary(data);
} catch (IllegalArgumentException e) {
LOG.info((e.getMessage()));
return false;
}
try (DBHandler dbHandler = GBApplication.acquireDB()) {
HPlusHealthSampleProvider provider = new HPlusHealthSampleProvider(getDevice(), dbHandler.getDaoSession());
HPlusHealthActivitySample sample = createSample(dbHandler, record.timestamp);
sample.setRawKind(record.type);
sample.setSteps(record.steps);
sample.setDistance(record.distance);
sample.setCalories(record.calories);
sample.setDistance(record.distance);
sample.setHeartRate((record.maxHeartRate - record.minHeartRate) / 2); //TODO: Find an alternative approach for Day Summary Heart Rate
sample.setRawHPlusHealthData(record.getRawData());
sample.setProvider(provider);
provider.addGBActivitySample(sample);
} catch (GBException ex) {
LOG.info((ex.getMessage()));
} catch (Exception ex) {
LOG.info(ex.getMessage());
}
mGetDaySummaryTime = GregorianCalendar.getInstance();
mGetDaySummaryTime.add(Calendar.SECOND, DAY_SUMMARY_SYNC_PERIOD);
return true;
}
/**
* Process a message containing information regarding firmware version
*
* @param data the message from the device
* @return boolean indicating success or fail
*/
public boolean processVersion(byte[] data) {
int major, minor;
LOG.info("Process Version Data: : '" + new String(data) + "'");
if (data.length >= 11) {
major = data[10] & 0xFF;
minor = data[9] & 0xFF;
int hwMajor = data[2] & 0xFF;
int hwMinor = data[1] & 0xFF;
getDevice().setFirmwareVersion2(hwMajor + "." + hwMinor);
mHPlusSupport.setUnicodeSupport(data[3] != 0);
} else {
major = data[2] & 0xFF;
minor = data[1] & 0xFF;
}
getDevice().setFirmwareVersion(major + "." + minor);
getDevice().sendDeviceUpdateIntent(getContext());
return true;
}
/**
* Issue a message requesting the next batch of sleep data
*/
private void requestNextSleepData() {
try {
TransactionBuilder builder = new TransactionBuilder("requestSleepStats");
builder.write(mHPlusSupport.ctrlCharacteristic, new byte[]{HPlusConstants.CMD_GET_SLEEP});
mHPlusSupport.performConnected(builder.getTransaction());
} catch (Exception e) {
}
mGetSleepTime = GregorianCalendar.getInstance();
mGetSleepTime.add(GregorianCalendar.SECOND, SLEEP_SYNC_RETRY_PERIOD);
}
/**
* Issue a message requesting the next set of slots
* The process will sync 1h at a time until the device is in sync
* Then it will request samples until the end of the day in order to minimize data loss
* Messages will be provided every 10 minutes after they are available
*/
private void requestNextDaySlots() {
Calendar now = GregorianCalendar.getInstance();
int currentSlot = now.get(Calendar.HOUR_OF_DAY) * 6 + now.get(Calendar.MINUTE) / 10;
//Finished dumping the entire ring buffer
//Sync to current time
mGetDaySlotsTime = now;
if (mSlotsInitialSync) {
if (mLastSlotReceived == 143) {
mSlotsInitialSync = false;
mGetDaySlotsTime.set(Calendar.SECOND, CURRENT_DAY_SYNC_PERIOD); //Sync complete. Delay timer forever
mLastSlotReceived = -1;
mLastSlotRequested = mLastSlotReceived + 1;
return;
} else {
mGetDaySlotsTime.add(Calendar.SECOND, CURRENT_DAY_SYNC_RETRY_PERIOD);
}
} else {
//Sync complete. Delay timer forever
mGetDaySlotsTime.set(Calendar.SECOND, CURRENT_DAY_SYNC_PERIOD);
return;
}
if (mLastSlotReceived == 143)
mLastSlotReceived = -1;
byte hour = (byte) ((mLastSlotReceived + 1) / 6);
byte minute = (byte) (((mLastSlotReceived + 1) % 6) * 10);
byte nextHour = hour;
byte nextMinute = 59;
mLastSlotRequested = nextHour * 6 + (nextMinute / 10);
byte[] msg = new byte[]{HPlusConstants.CMD_GET_ACTIVE_DAY, hour, minute, nextHour, nextMinute};
try {
TransactionBuilder builder = new TransactionBuilder("getNextDaySlot");
builder.write(mHPlusSupport.ctrlCharacteristic, msg);
mHPlusSupport.performConnected(builder.getTransaction());
} catch (Exception e) {
}
}
/**
* Request a batch of data with the summary of the previous days
*/
public void requestDaySummaryData() {
try {
TransactionBuilder builder = new TransactionBuilder("startSyncDaySummary");
builder.write(mHPlusSupport.ctrlCharacteristic, new byte[]{HPlusConstants.CMD_GET_DAY_DATA});
mHPlusSupport.performConnected(builder.getTransaction());
} catch (Exception e) {
}
mGetDaySummaryTime = GregorianCalendar.getInstance();
mGetDaySummaryTime.add(Calendar.SECOND, DAY_SUMMARY_SYNC_RETRY_PERIOD);
}
/**
* Helper function to create a sample
*
* @param dbHandler The database handler
* @param timestamp The sample timestamp
* @return The sample just created
*/
private HPlusHealthActivitySample createSample(DBHandler dbHandler, int timestamp) {
Long userId = DBHelper.getUser(dbHandler.getDaoSession()).getId();
Long deviceId = DBHelper.getDevice(getDevice(), dbHandler.getDaoSession()).getId();
HPlusHealthActivitySample sample = new HPlusHealthActivitySample(
timestamp, // ts
deviceId, userId, // User id
null, // Raw Data
ActivityKind.TYPE_UNKNOWN,
0, // Intensity
ActivitySample.NOT_MEASURED, // Steps
ActivitySample.NOT_MEASURED, // HR
ActivitySample.NOT_MEASURED, // Distance
ActivitySample.NOT_MEASURED // Calories
);
return sample;
}
public void setHPlusSupport(HPlusSupport HPlusSupport) {
LOG.info("Updating HPlusSupport object");
this.mHPlusSupport = HPlusSupport;
}
}