/* Copyright (C) 2019-2024 Andreas Shimokawa, Carsten Pfeiffer, Daniel Dakhno 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.qhybrid.adapter.misfit; import static nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.QHybridSupport.ITEM_ACTIVITY_POINT; import static nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.QHybridSupport.ITEM_STEP_COUNT; import static nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.QHybridSupport.ITEM_STEP_GOAL; import static nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.QHybridSupport.ITEM_VIBRATION_STRENGTH; import static nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.QHybridSupport.QHYBRID_EVENT_BUTTON_PRESS; import static nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.QHybridSupport.QHYBRID_EVENT_FILE_UPLOADED; import android.bluetooth.BluetoothGatt; import android.bluetooth.BluetoothGattCharacteristic; import android.content.Intent; import android.net.Uri; import android.util.SparseArray; import android.widget.Toast; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Date; import java.util.GregorianCalendar; import java.util.NoSuchElementException; import java.util.Queue; import java.util.TimeZone; import nodomain.freeyourgadget.gadgetbridge.BuildConfig; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo; import nodomain.freeyourgadget.gadgetbridge.devices.DeviceManager; import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.NotificationConfiguration; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.Alarm; import nodomain.freeyourgadget.gadgetbridge.model.BatteryState; import nodomain.freeyourgadget.gadgetbridge.model.GenericItem; import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.QHybridSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.adapter.WatchAdapter; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.Request; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.ActivityPointGetRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.AnimationRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.BatteryLevelRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.DownloadFileRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.EraseFileRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.FileRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.GetCountdownSettingsRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.GetCurrentStepCountRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.GetStepGoalRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.GetVibrationStrengthRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.GoalTrackingGetRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.ListFilesRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.MoveHandsRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.OTAEnterRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.OTAEraseRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.PlayNotificationRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.ReleaseHandsControlRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.RequestHandControlRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.SaveCalibrationRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.SetCurrentStepCountRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.SetStepGoalRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.SetTimeRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.SetVibrationStrengthRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.UploadFileRequest; import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.misfit.VibrateRequest; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; import nodomain.freeyourgadget.gadgetbridge.util.GB; public class MisfitWatchAdapter extends WatchAdapter { private int lastButtonIndex = -1; private final SparseArray responseFilters = new SparseArray<>(); private UploadFileRequest uploadFileRequest; private Request fileRequest = null; private Queue requestQueue = new ArrayDeque<>(); private Logger logger = LoggerFactory.getLogger(getClass()); public MisfitWatchAdapter(QHybridSupport deviceSupport) { super(deviceSupport); fillResponseList(); } @Override public void initialize() { requestQueue.add(new GetStepGoalRequest()); requestQueue.add(new GetVibrationStrengthRequest()); requestQueue.add(new ActivityPointGetRequest()); requestQueue.add(prepareSetTimeRequest()); requestQueue.add(new AnimationRequest()); requestQueue.add(new SetCurrentStepCountRequest((int) (999999 * getDeviceSupport().calculateNotificationProgress()))); queueWrite(new GetCurrentStepCountRequest()); getDeviceSupport().getDevice().setState(GBDevice.State.INITIALIZED); getDeviceSupport().getDevice().sendDeviceUpdateIntent(getContext()); } private SetTimeRequest prepareSetTimeRequest() { long millis = System.currentTimeMillis(); TimeZone zone = new GregorianCalendar().getTimeZone(); return new SetTimeRequest( (int) (millis / 1000 + getDeviceSupport().getTimeOffset() * 60), (short) (millis % 1000), (short) ((zone.getRawOffset() + zone.getDSTSavings()) / 60000)); } @Override public void playPairingAnimation() { queueWrite(new AnimationRequest()); } @Override public void playNotification(NotificationConfiguration config) { queueWrite(new PlayNotificationRequest( config.getVibration(), config.getHour(), config.getMin(), config.getSubEye() )); } @Override public void setTime() { queueWrite(prepareSetTimeRequest()); } @Override public boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { GBDevice gbDevice = getDeviceSupport().getDevice(); switch (characteristic.getUuid().toString()) { case "3dda0004-957f-7d4a-34a6-74696673696d": case "3dda0003-957f-7d4a-34a6-74696673696d": { return handleFileDownloadCharacteristic(characteristic); } case "3dda0007-957f-7d4a-34a6-74696673696d": { return handleFileUploadCharacteristic(characteristic); } case "3dda0002-957f-7d4a-34a6-74696673696d": { return handleBasicCharacteristic(characteristic); } case "3dda0006-957f-7d4a-34a6-74696673696d": { return handleButtonCharacteristic(characteristic); } case "00002a19-0000-1000-8000-00805f9b34fb": { short level = characteristic.getValue()[0]; gbDevice.setBatteryLevel(level); GBDeviceEventBatteryInfo batteryInfo = new GBDeviceEventBatteryInfo(); batteryInfo.level = gbDevice.getBatteryLevel(); batteryInfo.state = BatteryState.BATTERY_NORMAL; getDeviceSupport().handleGBDeviceEvent(batteryInfo); break; } default: { log("unknown shit on " + characteristic.getUuid().toString() + ": " + arrayToString(characteristic.getValue())); try { File charLog = FileUtils.getExternalFile("qFiles/charLog.txt"); try (FileOutputStream fos = new FileOutputStream(charLog, true)) { fos.write((new Date().toString() + ": " + characteristic.getUuid().toString() + ": " + arrayToString(characteristic.getValue())).getBytes()); } } catch (IOException e) { GB.log("error", GB.ERROR, e); } break; } } return getDeviceSupport().onCharacteristicChanged(gatt, characteristic); } private void fillResponseList() { Class[] classes = new Class[]{ BatteryLevelRequest.class, GetStepGoalRequest.class, GetVibrationStrengthRequest.class, GetCurrentStepCountRequest.class, OTAEnterRequest.class, GoalTrackingGetRequest.class, ActivityPointGetRequest.class, GetCountdownSettingsRequest.class }; for (Class c : classes) { try { c.getSuperclass().getDeclaredMethod("handleResponse", BluetoothGattCharacteristic.class); Request object = c.newInstance(); byte[] sequence = object.getStartSequence(); if (sequence.length > 1) { responseFilters.put((int) object.getStartSequence()[1], object); log("response filter " + object.getStartSequence()[1] + ": " + c.getSimpleName()); } } catch (NoSuchMethodException | IllegalAccessException | InstantiationException e) { log("skipping class " + c.getName()); } } } private boolean handleBasicCharacteristic(BluetoothGattCharacteristic characteristic) { byte[] values = characteristic.getValue(); Request request = resolveAnswer(characteristic); GBDevice gbDevice = getDeviceSupport().getDevice(); if (request == null) { StringBuilder valueString = new StringBuilder(String.valueOf(values[0])); for (int i = 1; i < characteristic.getValue().length; i++) { valueString.append(", ").append(values[i]); } log("unable to resolve " + characteristic.getUuid().toString() + ": " + valueString); return true; } log("response: " + request.getClass().getSimpleName()); request.handleResponse(characteristic); if (request instanceof GetStepGoalRequest) { gbDevice.addDeviceInfo(new GenericItem(ITEM_STEP_GOAL, String.valueOf(((GetStepGoalRequest) request).stepGoal))); } else if (request instanceof GetVibrationStrengthRequest) { int strength = ((GetVibrationStrengthRequest) request).strength; gbDevice.addDeviceInfo(new GenericItem(ITEM_VIBRATION_STRENGTH, String.valueOf(strength))); } else if (request instanceof GetCurrentStepCountRequest) { int steps = ((GetCurrentStepCountRequest) request).steps; logger.debug("get current steps: " + steps); try { File file = FileUtils.getExternalFile("qFiles/steps"); logger.debug("Writing file " + file.getPath()); try (FileOutputStream fos = new FileOutputStream(file, true)) { fos.write((System.currentTimeMillis() + ": " + steps + "\n").getBytes()); } logger.debug("file written."); } catch (Exception e) { GB.log("error", GB.ERROR, e); } gbDevice.addDeviceInfo(new GenericItem(ITEM_STEP_COUNT, String.valueOf(((GetCurrentStepCountRequest) request).steps))); } else if (request instanceof OTAEnterRequest) { if (((OTAEnterRequest) request).success) { fileRequest = new OTAEraseRequest(1024 << 16); queueWrite(fileRequest); } } else if (request instanceof ActivityPointGetRequest) { gbDevice.addDeviceInfo(new GenericItem(ITEM_ACTIVITY_POINT, String.valueOf(((ActivityPointGetRequest) request).activityPoint))); } try { queueWrite(requestQueue.remove()); } catch (NoSuchElementException e) { } LocalBroadcastManager.getInstance(getContext()).sendBroadcast(new Intent(DeviceManager.ACTION_DEVICES_CHANGED)); return true; } private Request resolveAnswer(BluetoothGattCharacteristic characteristic) { byte[] values = characteristic.getValue(); if (values[0] != 3) return null; return responseFilters.get(values[1]); } private boolean handleFileDownloadCharacteristic(BluetoothGattCharacteristic characteristic) { Request request; request = fileRequest; request.handleResponse(characteristic); if (request instanceof ListFilesRequest) { if (((ListFilesRequest) request).completed) { logger.debug("File count: " + ((ListFilesRequest) request).fileCount + " size: " + ((ListFilesRequest) request).size); if (((ListFilesRequest) request).fileCount == 0) return true; // queueWrite(new DownloadFileRequest((short) (256 + ((ListFilesRequest) request).fileCount))); } } else if (request instanceof DownloadFileRequest) { if (((FileRequest) request).completed) { logger.debug("file " + ((DownloadFileRequest) request).fileHandle + " completed: " + ((DownloadFileRequest) request).size); // backupFile((DownloadFileRequest) request); } } else if (request instanceof EraseFileRequest) { if (((EraseFileRequest) request).fileHandle > 257) { queueWrite(new DownloadFileRequest((short) (((EraseFileRequest) request).fileHandle - 1))); } } return true; } private boolean handleFileUploadCharacteristic(BluetoothGattCharacteristic characteristic) { if (uploadFileRequest == null) { logger.debug("no uploadFileRequest to handle response"); return true; } uploadFileRequest.handleResponse(characteristic); switch (uploadFileRequest.state) { case ERROR: Intent fileIntent = new Intent(QHYBRID_EVENT_FILE_UPLOADED); fileIntent.putExtra("EXTRA_ERROR", true); LocalBroadcastManager.getInstance(getContext()).sendBroadcast(fileIntent); uploadFileRequest = null; break; case UPLOAD: for (byte[] packet : this.uploadFileRequest.packets) { new TransactionBuilder("File upload").write(characteristic, packet).queue(getDeviceSupport().getQueue()); } break; case UPLOADED: fileIntent = new Intent(QHYBRID_EVENT_FILE_UPLOADED); LocalBroadcastManager.getInstance(getContext()).sendBroadcast(fileIntent); uploadFileRequest = null; break; } return true; } private boolean handleButtonCharacteristic(BluetoothGattCharacteristic characteristic) { byte[] value = characteristic.getValue(); if (value.length != 11) { logger.debug("wrong button message"); return true; } int index = value[6] & 0xFF; int button = value[8] >> 4 & 0xFF; if (index != this.lastButtonIndex) { lastButtonIndex = index; logger.debug("Button press on button " + button); Intent i = new Intent(QHYBRID_EVENT_BUTTON_PRESS); i.setPackage(BuildConfig.APPLICATION_ID); i.putExtra("BUTTON", button); //ByteBuffer buffer = ByteBuffer.allocate(16); //buffer.put(new byte[]{0x01, 0x00, 0x08}); //buffer.put(value, 2, 8); //buffer.put(new byte[]{(byte)0xFF, 0x05, 0x00, 0x01, 0x00}); //FilePutRequest request = new FilePutRequest((short)0, buffer.array()); //for(byte[] packet : request.packets){ // new TransactionBuilder("File upload").write(getCharacteristic(UUID.fromString("3dda0007-957f-7d4a-34a6-74696673696d")), packet).queue(getQueue()); //} getContext().sendBroadcast(i); } return true; } private void log(String message){ logger.debug(message); } public void setActivityHand(double progress) { queueWrite(new SetCurrentStepCountRequest(Math.min((int) (1000000 * progress), 999999))); } public void vibrate(PlayNotificationRequest.VibrationType vibration) { queueWrite(new PlayNotificationRequest(vibration, -1, -1)); } @Override public void vibrateFindMyDevicePattern() { queueWrite(new VibrateRequest(false, (short) 4, (short) 1)); } @Override public void requestHandsControl() { queueWrite(new RequestHandControlRequest()); } @Override public void releaseHandsControl() { queueWrite(new ReleaseHandsControlRequest()); } @Override public void setHands(MoveHandsRequest.MovementConfiguration movement) { queueWrite(new MoveHandsRequest(movement)); } @Override public void saveCalibration() { queueWrite(new SaveCalibrationRequest()); } @Override public void setStepGoal(int stepGoal) { queueWrite(new SetStepGoalRequest(stepGoal)); } @Override public void setVibrationStrength(short strength) { queueWrite(new SetVibrationStrengthRequest(strength)); } @Override public void syncNotificationSettings() { } @Override public void onTestNewFunction() { } @Override public void setTimezoneOffsetMinutes(short offset) { GB.toast("old firmware does't support timezones", Toast.LENGTH_LONG, GB.ERROR); } @Override public void onInstallApp(Uri uri) { } @Override public boolean supportsFindDevice() { return supportsExtendedVibration(); } @Override public boolean supportsExtendedVibration() { String modelNumber = getDeviceSupport().getDevice().getModel(); switch (modelNumber) { case "HW.0.0": return true; case "HL.0.0": return false; case "DN.1.0": return true; } throw new UnsupportedOperationException("Model " + modelNumber + " not supported"); } @Override public boolean supportsActivityHand() { String modelNumber = getDeviceSupport().getDevice().getModel(); switch (modelNumber) { case "HW.0.0": return true; case "HL.0.0": return false; case "DN.1.0": return false; } throw new UnsupportedOperationException("Model " + modelNumber + " not supported"); } @Override public void onFetchActivityData() { requestQueue.add(new BatteryLevelRequest()); requestQueue.add(new GetCurrentStepCountRequest()); // requestQueue.add(new ListFilesRequest()); queueWrite(new ActivityPointGetRequest()); getDeviceSupport().getDevice().unsetBusyTask(); GB.updateTransferNotification(null, "", false, 100, getContext()); getDeviceSupport().getDevice().sendDeviceUpdateIntent(getContext()); GB.signalActivityDataFinish(getDeviceSupport().getDevice()); } @Override public void onSetAlarms(ArrayList alarms) { GB.toast("alarms not supported with this firmware", Toast.LENGTH_LONG, GB.ERROR); return; } @Override public void onSendConfiguration(String config) { } @Override public void overwriteButtons(String jsonConfigString) { uploadFileRequest = new UploadFileRequest((short) 0x0800, new byte[]{ (byte) 0x01, (byte) 0x00, (byte) 0x00, (byte) 0x03, (byte) 0x10, (byte) 0x01, (byte) 0x01, (byte) 0x01, (byte) 0x0C, (byte) 0x00, (byte) 0x00, (byte) 0x20, (byte) 0x01, (byte) 0x01, (byte) 0x01, (byte) 0x0C, (byte) 0x00, (byte) 0x00, (byte) 0x30, (byte) 0x01, (byte) 0x01, (byte) 0x01, (byte) 0x0C, (byte) 0x00, (byte) 0x00, (byte) 0x01, (byte) 0x01, (byte) 0x00, (byte) 0x01, (byte) 0x01, (byte) 0x0C, (byte) 0x2E, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x01, (byte) 0x00, (byte) 0x06, (byte) 0x00, (byte) 0x01, (byte) 0x01, (byte) 0x01, (byte) 0x03, (byte) 0x00, (byte) 0x02, (byte) 0x01, (byte) 0x0F, (byte) 0x00, (byte) 0x8B, (byte) 0x00, (byte) 0x00, (byte) 0x93, (byte) 0x00, (byte) 0x01, (byte) 0x08, (byte) 0x01, (byte) 0x14, (byte) 0x00, (byte) 0x01, (byte) 0x00, (byte) 0xFE, (byte) 0x08, (byte) 0x00, (byte) 0x93, (byte) 0x00, (byte) 0x02, (byte) 0x01, (byte) 0x00, (byte) 0xBF, (byte) 0xD5, (byte) 0x54, (byte) 0xD1, (byte) 0x00 }); queueWrite(uploadFileRequest); } private void queueWrite(Request request) { new TransactionBuilder(request.getClass().getSimpleName()).write(getDeviceSupport().getCharacteristic(request.getRequestUUID()), request.getRequestData()).queue(getDeviceSupport().getQueue()); // if (request instanceof FileRequest) this.fileRequest = request; if (!request.expectsResponse()) { try { queueWrite(requestQueue.remove()); } catch (NoSuchElementException e) { } } } }