/* Copyright (C) 2023-2024 Johannes Krude 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.casio.gbx100; import android.bluetooth.BluetoothGatt; import android.bluetooth.BluetoothGattCharacteristic; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.util.ArrayList; import java.util.Calendar; import java.util.TimeZone; import java.util.UUID; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.devices.casio.CasioConstants; import nodomain.freeyourgadget.gadgetbridge.entities.CasioGBX100ActivitySample; import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEOperation; import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; import nodomain.freeyourgadget.gadgetbridge.service.devices.miband.operations.OperationStatus; import nodomain.freeyourgadget.gadgetbridge.util.GB; public class FetchStepCountDataOperation extends AbstractBTLEOperation { private static final Logger LOG = LoggerFactory.getLogger(FetchStepCountDataOperation.class); private final CasioGBX100DeviceSupport support; public FetchStepCountDataOperation(CasioGBX100DeviceSupport support) { super(support); this.support = support; } private void enableRequiredNotifications(boolean enable) { try { TransactionBuilder builder = performInitialized("enableRequiredNotifications"); builder.setCallback(this); builder.notify(getCharacteristic(CasioConstants.CASIO_DATA_REQUEST_SP_CHARACTERISTIC_UUID), enable); builder.notify(getCharacteristic(CasioConstants.CASIO_CONVOY_CHARACTERISTIC_UUID), enable); builder.queue(getQueue()); } catch(IOException e) { LOG.error("Error enabling required notifications", e); } } private void requestStepCountData() { byte[] command = {0x00, 0x11, 0x00, 0x00, 0x00}; try { TransactionBuilder builder = performInitialized("requestStepCountDate"); builder.setCallback(this); builder.write(getCharacteristic(CasioConstants.CASIO_DATA_REQUEST_SP_CHARACTERISTIC_UUID), command); builder.queue(getQueue()); } catch(IOException e) { LOG.error("Error requesting step count data", e); } } private void writeStepCountAck() { byte[] command = {0x04, 0x11, 0x00, 0x00, 0x00}; try { TransactionBuilder builder = performInitialized("writeStepCountAck"); builder.setCallback(this); builder.write(getCharacteristic(CasioConstants.CASIO_DATA_REQUEST_SP_CHARACTERISTIC_UUID), command); builder.queue(getQueue()); } catch(IOException e) { LOG.error("Error writing step count ack", e); } } @Override protected void prePerform() throws IOException { super.prePerform(); getDevice().setBusyTask("FetchStepCountDataOperation starting..."); // mark as busy quickly to avoid interruptions from the outside GB.updateTransferNotification(null, getContext().getString(R.string.busy_task_fetch_activity_data), true, 0, getContext()); } @Override protected void doPerform() throws IOException { enableRequiredNotifications(true); requestStepCountData(); } @Override protected void operationFinished() { LOG.info("FetchStepCountDataOperation finished"); unsetBusy(); GB.updateTransferNotification(null, getContext().getString(R.string.busy_task_fetch_activity_data), false, 100, getContext()); GB.signalActivityDataFinish(getDevice()); operationStatus = OperationStatus.FINISHED; if (getDevice() != null) { try { TransactionBuilder builder = performInitialized("finished operation"); builder.setCallback(null); // unset ourselves from being the queue's gatt callback builder.wait(0); builder.queue(getQueue()); } catch (IOException ex) { LOG.error("Error resetting Gatt callback", ex); } } } @Override public boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { UUID characteristicUUID = characteristic.getUuid(); byte[] data = characteristic.getValue(); if (data.length == 0) return true; if (characteristicUUID.equals(CasioConstants.CASIO_DATA_REQUEST_SP_CHARACTERISTIC_UUID)) { int length = 0; if (data.length > 3) { length = (data[2] & 0xff) | ((data[3] & 0xff) << 8); } LOG.debug("Response is going to be {} bytes long", length); GB.updateTransferNotification(null, getContext().getString(R.string.busy_task_fetch_activity_data), true, 10, getContext()); return true; } else if(characteristicUUID.equals(CasioConstants.CASIO_CONVOY_CHARACTERISTIC_UUID)) { if(data.length < 18) { LOG.info("Data length too short."); } else { for(int i=0; i stepCountData = new ArrayList<>(); int year = data[2]; int month = data[3] - 1; // Zero-based int day = data[4]; int hour = data[5]; int minute = data[6]; int stepCount = ((data[7] & 0xff) | ((data[8] & 0xff) << 8) | ((data[9] & 0xff) << 16) | ((data[10] & 0xff) << 24)); // it reports 0xfffffffe if no steps have been recorded if(stepCount == 0xfffffffe) stepCount = 0; int calories = ((data[11] & 0xff) | ((data[12] & 0xff) << 8)); if(calories == 0xfffe) calories = 0; int yearOfBirth = ((data[13] & 0xff) | ((data[14] & 0xff) << 8)); int monthOfBirth = data[15]; int dayOfBirth = data[16]; LOG.debug("Current step count value: {}", stepCount); LOG.debug("Current calories: {}", calories); // data[17]: // 0x01 probably means finished. // 0x00 probably means more data. // Set timestamps for retrieving recorded data for the current day cal.set(year + 2000, month, day, hour, 30, 0); int ts_to = (int)(cal.getTimeInMillis() / 1000); cal.set(year + 2000, month, day, 0, 0, 0); int ts_from = (int)(cal.getTimeInMillis() / 1000); CasioGBX100ActivitySample sum = support.getSumWithinRange(ts_from, ts_to); int caloriesToday = sum.getCalories(); int stepsToday = sum.getSteps(); // Set timestamp to currently fetched data for fetching historic data cal.set(year + 2000, month, day, hour, 30, 0); if(data[17] == 0x00 && data.length > 18) { LOG.info("We got historic step count data."); int index = 18; boolean inPacket = false; int packetIndex = 0; int packetLength = 0; int type = 0; while(index < data.length) { if(!inPacket) { type = data[index]; packetLength = ((data[index + 1] & 0xff) | ((data[index + 2] & 0xff) << 8)); packetIndex = 0; inPacket = true; index = index + 3; LOG.debug("Decoding packet with type: {} and length: {}", type, packetLength); } int count = ((data[index] & 0xff) | ((data[index + 1] & 0xff) << 8)); if(count == 0xfffe) count = 0; LOG.debug("Got count {}", count); index = index+2; if(index >= data.length) { LOG.debug("End of packet."); } if(type == CasioConstants.CASIO_CONVOY_DATATYPE_STEPS) { cal.add(Calendar.HOUR, -1); int ts = (int)(cal.getTimeInMillis() / 1000); stepCountData.add(new CasioGBX100ActivitySample()); stepCountData.get(packetIndex/2).setSteps(count); stepCountData.get(packetIndex/2).setTimestamp(ts); if(count > 0) { stepCountData.get(packetIndex / 2).setRawKind(ActivityKind.ACTIVITY.getCode()); } else { stepCountData.get(packetIndex / 2).setRawKind(ActivityKind.NOT_MEASURED.getCode()); } if(ts > ts_from && ts < ts_to) { stepsToday += count; } } else if(type == CasioConstants.CASIO_CONVOY_DATATYPE_CALORIES) { if(stepCountData.get(packetIndex/2).getSteps() > 0) { // The last packet might contain an invalid calory count // of 255, but only if the steps are also invalid. stepCountData.get(packetIndex / 2).setCalories(count); int ts = stepCountData.get(packetIndex / 2).getTimestamp(); if (ts > ts_from && ts < ts_to) { caloriesToday += count; } } } packetIndex = packetIndex + 2; if(packetIndex >= packetLength) inPacket = false; } } // This generates an artificial "now" timestamp for the current // activity based on the existing data. This timestamp will be overwritten // by the next fetch operation with the actual value. int steps = stepCount - stepsToday; int cals = calories - caloriesToday; // For a yet unknown reason, the sum calculated by the watch is sometimes lower than // the sum calculated by us. I suspect it is just refreshed at a later time!? if(steps > 0 && cals > 0) { cal.set(year + 2000, month, day, hour, 30, 0); int ts = (int) (cal.getTimeInMillis() / 1000); LOG.debug("Artificial timestamp: {} calories and {} steps", cals, steps); CasioGBX100ActivitySample sample = new CasioGBX100ActivitySample(); sample.setSteps(steps); sample.setCalories(cals); sample.setTimestamp(ts); if (steps > 0) sample.setRawKind(ActivityKind.ACTIVITY.getCode()); else sample.setRawKind(ActivityKind.NOT_MEASURED.getCode()); stepCountData.add(0, sample); } support.stepCountDataFetched(stepCount, calories, stepCountData); } GB.updateTransferNotification(null, getContext().getString(R.string.busy_task_fetch_activity_data), true, 80, getContext()); writeStepCountAck(); return true; } else { LOG.warn("Unhandled characteristic changed: {}", characteristicUUID); return super.onCharacteristicChanged(gatt, characteristic); } } @Override public boolean onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { UUID characteristicUUID = characteristic.getUuid(); byte[] data = characteristic.getValue(); if (data.length == 0) return true; if (characteristicUUID.equals(CasioConstants.CASIO_DATA_REQUEST_SP_CHARACTERISTIC_UUID)) { if(data[0] == 0x00) { LOG.debug("Request sent successfully"); } else if(data[0] == 0x04) { LOG.debug("Read step count operation finished"); enableRequiredNotifications(false); operationFinished(); } return true; } return super.onCharacteristicWrite(gatt, characteristic, status); } }