diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java index 381450551b..d03701bc37 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java @@ -242,6 +242,7 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator { developer.add(R.xml.devicesettings_keep_activity_data_on_device); developer.add(R.xml.devicesettings_fetch_unknown_files); developer.add(R.xml.devicesettings_new_sync_protocol); + developer.add(R.xml.devicesettings_garmin_mlr); return deviceSpecificSettings; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java index cd030b237f..d09536dbcc 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java @@ -267,6 +267,14 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC return communicator.onCharacteristicChanged(gatt, characteristic, value); } + @Override + public void onConnectionStateChange(final BluetoothGatt gatt, final int status, final int newState) { + super.onConnectionStateChange(gatt, status, newState); + if (communicator != null) { + communicator.onConnectionStateChange(gatt, status, newState); + } + } + @Override public void onMessage(final byte[] message) { if (null == message) { @@ -277,9 +285,11 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC GFDIMessage parsedMessage = GFDIMessage.parseIncoming(message); if (null == parsedMessage) { + LOG.error("GFDIMessage is null - this should never happen"); return; //message cannot be handled } + LOG.debug("Got GFDIMessage {} ({} bytes)", parsedMessage.getClass().getSimpleName(), message.length); /* the handler elaborates the followup message but might change the status message since it does @@ -443,13 +453,13 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC @Override public void onFetchRecordedData(final int dataTypes) { - if (this.supportedFileTypeList.isEmpty()) { - LOG.warn("No known supported file types"); + if (dataTypes == RecordedDataTypes.TYPE_DEBUGLOGS) { + sendOutgoingMessage("fetch debug data", fileTransferHandler.initiateDebugDownload()); return; } - if (dataTypes == RecordedDataTypes.TYPE_DEBUGLOGS){ - sendOutgoingMessage("fetch debug data", fileTransferHandler.initiateDebugDownload()); + if (this.supportedFileTypeList.isEmpty() && !newSyncProtocol()) { + LOG.warn("No known supported file types"); return; } @@ -464,6 +474,10 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC return getDevicePrefs().getBoolean("new_sync_protocol", false); } + public boolean mlrEnabled() { + return getDevicePrefs().getBoolean("garmin_mlr", false); + } + @Override public void onNotification(final NotificationSpec notificationSpec) { sendOutgoingMessage("send notification " + notificationSpec.getId(), notificationsHandler.onNotification(notificationSpec)); @@ -1231,7 +1245,7 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC } public void downloadFileFromServiceV2(final int fileHandle) { - LOG.warn("Requesting file service V2 handle={}", fileHandle); + LOG.info("Requesting file service V2 handle={}", fileHandle); if (!(communicator instanceof CommunicatorV2 communicatorV2)) { LOG.error("Communicator is not V2"); return; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/CobsCoDec.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/CobsCoDec.java index 9d4a002db7..58b290aea1 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/CobsCoDec.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/CobsCoDec.java @@ -79,7 +79,7 @@ public class CobsCoDec { } // this implementation of COBS relies on a leading and a trailing 0 byte (the former is not part of default implementations) - public byte[] encode(byte[] data) { + public static byte[] encode(byte[] data) { ByteBuffer encodedBytesBuffer = ByteBuffer.allocate((data.length * 2) + 1); // Maximum expansion encodedBytesBuffer.put((byte) 0);// Garmin initial padding diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/ICommunicator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/ICommunicator.java index 0d93f0bf6d..b301d6ccec 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/ICommunicator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/ICommunicator.java @@ -12,6 +12,10 @@ public interface ICommunicator { boolean initializeDevice(TransactionBuilder builder); + void dispose(); + + void onConnectionStateChange(BluetoothGatt gatt, int status, int newState); + boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, byte[] value); void onHeartRateTest(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/v1/CommunicatorV1.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/v1/CommunicatorV1.java index 615fc47e3a..9a12c3fc0b 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/v1/CommunicatorV1.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/v1/CommunicatorV1.java @@ -69,6 +69,16 @@ public class CommunicatorV1 implements ICommunicator { return true; } + @Override + public void dispose() { + + } + + @Override + public void onConnectionStateChange(final BluetoothGatt gatt, final int status, final int newState) { + + } + @Override public void sendMessage(final String taskName, final byte[] message) { if (null == message) diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/v2/CommunicatorV2.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/v2/CommunicatorV2.java index 8a0e62edb7..1d19bfad86 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/v2/CommunicatorV2.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/v2/CommunicatorV2.java @@ -19,6 +19,7 @@ import java.util.Arrays; import java.util.Date; import java.util.HashMap; import java.util.Map; +import java.util.Objects; import java.util.UUID; import nodomain.freeyourgadget.gadgetbridge.GBApplication; @@ -33,6 +34,7 @@ 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.BLETypeConversions; import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminTimeUtils; @@ -57,30 +59,24 @@ public class CommunicatorV2 implements ICommunicator { private final Map handleByService = new HashMap<>(); private final Map serviceCallbacks = new HashMap<>(); - private int gfdiHandle = 0; public int maxWriteSize = 20; - public final CobsCoDec cobsCoDec; - private int realtimeHrHandle = 0; private boolean realtimeHrOneShot = false; - - private int realtimeStepsHandle = 0; private int previousSteps = -1; - private int realtimeAccelHandle = 0; - - private int realtimeSpo2Handle = 0; - private int realtimeRespirationHandle = 0; - private int realtimeHrvHandle = 0; + // MLR support + private final Map mlrCommunicators = new HashMap<>(); public CommunicatorV2(final GarminSupport garminSupport) { this.mSupport = garminSupport; - this.cobsCoDec = new CobsCoDec(); } @Override public void onMtuChanged(final int mtu) { maxWriteSize = calcMaxWriteChunk(mtu); + for (MlrCommunicator communicator : mlrCommunicators.values()) { + communicator.setMaxPacketSize(maxWriteSize); + } } @Override @@ -106,28 +102,49 @@ public class CommunicatorV2 implements ICommunicator { return false; } + @Override + public void dispose() { + // Close all MLR communicators + for (MlrCommunicator mlrComm : mlrCommunicators.values()) { + mlrComm.close(); + } + mlrCommunicators.clear(); + } + + @Override + public void onConnectionStateChange(final BluetoothGatt gatt, final int status, final int newState) { + for (MlrCommunicator mlrComm : mlrCommunicators.values()) { + mlrComm.onConnectionStateChange(gatt, status, newState); + } + } + @Override public void sendMessage(final String taskName, final byte[] message) { if (null == message) return; - if (0 == gfdiHandle) { + final Integer gfdiHandle = handleByService.get(Service.GFDI); + if (gfdiHandle == null) { LOG.error("CANNOT SENT GFDI MESSAGE, HANDLE NOT YET SET. MESSAGE {}", message); return; } - final byte[] payload = cobsCoDec.encode(message); -// LOG.debug("SENDING MESSAGE: {} - COBS ENCODED: {}", GB.hexdump(message), GB.hexdump(payload)); + final byte[] payload = CobsCoDec.encode(message); + final MlrCommunicator mlr = mlrCommunicators.get(gfdiHandle); + if (mlr != null) { + mlr.sendMessage(taskName, payload); + return; + } final TransactionBuilder builder = mSupport.createTransactionBuilder(taskName); int remainingBytes = payload.length; if (remainingBytes > maxWriteSize - 1) { int position = 0; while (remainingBytes > 0) { final byte[] fragment = Arrays.copyOfRange(payload, position, position + Math.min(remainingBytes, maxWriteSize - 1)); - builder.write(characteristicSend, ArrayUtils.addAll(new byte[]{(byte) gfdiHandle}, fragment)); + builder.write(characteristicSend, ArrayUtils.addAll(new byte[]{gfdiHandle.byteValue()}, fragment)); position += fragment.length; remainingBytes -= fragment.length; } } else { - builder.write(characteristicSend, ArrayUtils.addAll(new byte[]{(byte) gfdiHandle}, payload)); + builder.write(characteristicSend, ArrayUtils.addAll(new byte[]{gfdiHandle.byteValue()}, payload)); } builder.queue(); } @@ -139,37 +156,36 @@ public class CommunicatorV2 implements ICommunicator { return false; } + if ((value[0] & MlrCommunicator.MLR_FLAG_MASK) != 0) { + // MLR packet - extract handle and forward to appropriate MLR communicator, but keep the mlr flag + final int handle = ((value[0] & MlrCommunicator.HANDLE_MASK) >> 4) | MlrCommunicator.MLR_FLAG_MASK; + final MlrCommunicator mlrComm = mlrCommunicators.get(handle); + if (mlrComm == null) { + LOG.warn("Received MLR packet for unknown handle: {}", handle); + return true; + } + mlrComm.onPacketReceived(value); + return true; + } + final ByteBuffer message = ByteBuffer.wrap(value).order(ByteOrder.LITTLE_ENDIAN); final byte handle = message.get(); if (0x00 == handle) { processHandleManagement(message); - } else if (this.gfdiHandle == handle) { - processGfdi(message); - } else if (this.realtimeHrHandle == handle) { - processRealtimeHeartRate(message); - } else if (this.realtimeStepsHandle == handle) { - processRealtimeSteps(message); - } else if (this.realtimeAccelHandle == handle) { - processRealtimeAccelerometer(message); - } else if (this.realtimeSpo2Handle == handle) { - processRealtimeSpo2(message); - } else if (this.realtimeRespirationHandle == handle) { - processRealtimeRespiration(message); - } else if (this.realtimeHrvHandle == handle) { - processRealtimeHrv(message); - } else { - final Service service = serviceByHandle.get(handle & 0xff); - if (service != null) { - final ServiceCallback serviceCallback = serviceCallbacks.get(service); - if (serviceCallback != null) { - serviceCallback.onMessage(Arrays.copyOfRange(value, 1, value.length)); - } else { - LOG.warn("Got message for {}, but no callback found", service); - } + return true; + } + + final Service service = serviceByHandle.get(handle & 0xff); + if (service != null) { + final ServiceCallback serviceCallback = serviceCallbacks.get(service); + if (serviceCallback != null) { + serviceCallback.onMessage(Arrays.copyOfRange(value, 1, value.length)); } else { - LOG.warn("Got message for unknown service on handle {}: {}", handle, GB.hexdump(value)); + LOG.warn("Got message for {}, but no callback found", service); } + } else { + LOG.warn("Got message for unknown service on handle {}: {}", handle, GB.hexdump(value)); } return true; @@ -197,14 +213,14 @@ public class CommunicatorV2 implements ICommunicator { serviceCallbacks.put(service, callback); mSupport.createTransactionBuilder("start file transfer") - .write(characteristicSend, registerService(service, false)) + .write(characteristicSend, registerService(service, mSupport.mlrEnabled())) .queue(); } @Override public void onHeartRateTest() { realtimeHrOneShot = true; - if (realtimeHrHandle == 0) { + if (!handleByService.containsKey(Service.REALTIME_HR)) { mSupport.createTransactionBuilder("heart rate test") .write(characteristicSend, registerService(Service.REALTIME_HR, false)) .queue(); @@ -213,17 +229,18 @@ public class CommunicatorV2 implements ICommunicator { @Override public void onEnableRealtimeHeartRateMeasurement(final boolean enable) { - toggleService(Service.REALTIME_HR, realtimeHrHandle, enable); + toggleService(Service.REALTIME_HR, enable); } @Override public void onEnableRealtimeSteps(final boolean enable) { - if (toggleService(Service.REALTIME_STEPS, realtimeStepsHandle, enable)) { + if (toggleService(Service.REALTIME_STEPS, enable)) { previousSteps = -1; } } - private boolean toggleService(final Service service, final int currentHandle, final boolean enable) { + private boolean toggleService(final Service service, final boolean enable) { + final int currentHandle = Objects.requireNonNull(handleByService.getOrDefault(service, 0)); if (enable && currentHandle == 0) { mSupport.createTransactionBuilder(service + " = true") .write(characteristicSend, registerService(service, false)) @@ -273,52 +290,57 @@ public class CommunicatorV2 implements ICommunicator { LOG.warn("Failed to register {}, status={}", registeredService, status); return; } - final int handle = message.get(); + final int handle = message.get() & 0xff; final int reliable = message.get(); LOG.debug("Got register response for {}, handle={}, reliable={}", registeredService, handle, reliable); serviceByHandle.put(handle, registeredService); handleByService.put(registeredService, handle); - switch (registeredService) { + final ServiceCallback serviceCallback = switch (registeredService) { case GFDI: - this.gfdiHandle = handle; - break; + yield new GfdiCallback(mSupport); case REALTIME_HR: - this.realtimeHrHandle = handle; - break; + yield new RealtimeHeartRateCallback(); case REALTIME_STEPS: - this.realtimeStepsHandle = handle; - break; + yield new RealtimeStepsCallback(); case REALTIME_ACCELEROMETER: - this.realtimeAccelHandle = handle; - mSupport.createTransactionBuilder("start realtime accel") - .write(characteristicSend, new byte[]{(byte) handle, 0x01}) - .queue(); - break; + yield new RealtimeAccelerometerCallback(); case REALTIME_SPO2: - this.realtimeSpo2Handle = handle; - break; + yield new RealtimeSpo2Callback(); case REALTIME_RESPIRATION: - this.realtimeRespirationHandle = handle; - break; + yield new RealtimeRespirationCallback(); case REALTIME_HRV: - this.realtimeHrvHandle = handle; - break; + yield new RealtimeHrvCallback(); case FILE_TRANSFER_2: case FILE_TRANSFER_4: case FILE_TRANSFER_6: case FILE_TRANSFER_A: case FILE_TRANSFER_C: case FILE_TRANSFER_E: - final ServiceCallback serviceCallback = serviceCallbacks.get(registeredService); - if (serviceCallback == null) { - LOG.error("Got file transfer registration, but got no callback"); - closeService(registeredService, handle); - break; - } else { - serviceCallback.onConnect(new ServiceWriter(handle)); - } + // For these, the callback should have been provided by the caller in startTransfer + yield serviceCallbacks.get(registeredService); + default: + LOG.error("Got register response for unknown service {}", registeredService); + yield null; + }; + + if (serviceCallback == null) { + LOG.error("Got service registration, but got no callback"); + closeService(registeredService, handle); + return; + } + + serviceCallbacks.put(registeredService, serviceCallback); + + if (reliable != 0) { + // MLR mode - create reliable communicator + final MlrCommunicator mlrComm = createMlrCommunicator(handle, serviceCallback); + mlrCommunicators.put(handle, mlrComm); + serviceCallback.onConnect(new MlrServiceWriter(mlrComm)); + } else { + // Regular ML mode + serviceCallback.onConnect(new MlServiceWriter(handle)); } break; } @@ -329,60 +351,37 @@ public class CommunicatorV2 implements ICommunicator { final byte status = message.get(); LOG.debug("Received close handle response: service={}, handle={}, status={}", service, handle, status); if (service != null) { - switch (service) { - case GFDI: - this.gfdiHandle = 0; - break; - case REALTIME_HR: - this.realtimeHrHandle = 0; - break; - case REALTIME_STEPS: - this.realtimeStepsHandle = 0; - break; - case REALTIME_ACCELEROMETER: - this.realtimeAccelHandle = 0; - break; - case REALTIME_SPO2: - this.realtimeSpo2Handle = 0; - break; - case REALTIME_RESPIRATION: - this.realtimeRespirationHandle = 0; - break; - case REALTIME_HRV: - this.realtimeHrvHandle = 0; - break; - case FILE_TRANSFER_2: - case FILE_TRANSFER_4: - case FILE_TRANSFER_6: - case FILE_TRANSFER_A: - case FILE_TRANSFER_C: - case FILE_TRANSFER_E: - final ServiceCallback serviceCallback = serviceCallbacks.get(service); - if (serviceCallback == null) { - LOG.error("Got file transfer registration, but got no callback"); - } else { - serviceCallback.onClose(); - } - break; + final ServiceCallback serviceCallback = serviceCallbacks.get(service); + if (serviceCallback == null) { + LOG.error("Got service registration close, but got no callback"); + } else { + serviceCallback.onClose(); } + // Clean up MLR communicator if it exists + final MlrCommunicator mlrComm = mlrCommunicators.get(handle); + if (mlrComm != null) { + mlrComm.close(); + mlrCommunicators.remove(handle); + } + + handleByService.remove(service); + serviceCallbacks.remove(service); } serviceByHandle.remove(handle); - handleByService.remove(service); - serviceCallbacks.remove(service); + break; } case CLOSE_ALL_RESP: LOG.debug("Received close all handles response. Message: {}", message.array()); - this.gfdiHandle = 0; - this.realtimeHrHandle = 0; - this.realtimeStepsHandle = 0; - this.realtimeAccelHandle = 0; - this.realtimeSpo2Handle = 0; - this.realtimeRespirationHandle = 0; - this.realtimeHrvHandle = 0; + serviceByHandle.clear(); + handleByService.clear(); + for (ServiceCallback callback : serviceCallbacks.values()) { + callback.onClose(); + } + serviceCallbacks.clear(); mSupport.createTransactionBuilder("open GFDI") - .write(characteristicSend, registerService(Service.GFDI, false)) + .write(characteristicSend, registerService(Service.GFDI, mSupport.mlrEnabled())) .queue(); break; case UNK_RESP: @@ -391,69 +390,95 @@ public class CommunicatorV2 implements ICommunicator { } } - private void processGfdi(final ByteBuffer message) { - final byte[] partial = new byte[message.remaining()]; - message.get(partial); - this.cobsCoDec.receivedBytes(partial); + private static class GfdiCallback implements ServiceCallback { + private final CobsCoDec cobsCoDec = new CobsCoDec(); + private final GarminSupport mSupport; - this.mSupport.onMessage(this.cobsCoDec.retrieveMessage()); + private GfdiCallback(final GarminSupport support) { + this.mSupport = support; + } + + @Override + public void onMessage(final byte[] value) { + this.cobsCoDec.receivedBytes(value); + this.mSupport.onMessage(this.cobsCoDec.retrieveMessage()); + } } - private void processRealtimeHeartRate(final ByteBuffer buf) { - final byte type = buf.get(); // 0/2/3? 3 == realtime? - final int hr = buf.get(); - final int resting = buf.get(); - // ff ff after - LOG.debug("Got realtime HR: type={} hr={} resting={}", type, hr, resting); + private class RealtimeHeartRateCallback implements ServiceCallback { + @Override + public void onMessage(final byte[] value) { + final byte type = value[0]; // 0/2/3? 3 == realtime? + final int hr = value[0] & 0xff; + final int resting = value[0] & 0xff; + // ff ff after + LOG.debug("Got realtime HR: type={} hr={} resting={}", type, hr, resting); - if (hr > 0) { - broadcastRealtimeActivity(hr, -1); + if (hr > 0) { + broadcastRealtimeActivity(hr, -1); - if (realtimeHrOneShot && realtimeHrHandle != 0) { - onEnableRealtimeHeartRateMeasurement(false); + if (realtimeHrOneShot && handleByService.containsKey(Service.REALTIME_HR)) { + onEnableRealtimeHeartRateMeasurement(false); + } } } } - private void processRealtimeSteps(final ByteBuffer buf) { - final int steps = buf.getInt(); - final int goal = buf.getInt(); - LOG.debug("Got realtime steps: steps={} goal={}", steps, goal); + private class RealtimeStepsCallback implements ServiceCallback { + @Override + public void onMessage(final byte[] value) { + final int steps = BLETypeConversions.toUint32(value, 0); + final int goal = BLETypeConversions.toUint32(value, 4); + LOG.debug("Got realtime steps: steps={} goal={}", steps, goal); + + if (previousSteps == -1) { + previousSteps = steps; + } + + broadcastRealtimeActivity(-1, steps - previousSteps); - if (previousSteps == -1) { previousSteps = steps; } - - broadcastRealtimeActivity(-1, steps - previousSteps); - - previousSteps = steps; } - private void processRealtimeAccelerometer(final ByteBuffer message) { - final byte[] partial = new byte[message.remaining()]; - message.get(partial); + private static class RealtimeAccelerometerCallback implements ServiceCallback { + @Override + public void onConnect(final ServiceWriter writer) { + writer.write("start realtime accel", new byte[]{0x01}); + } - LOG.debug("Got realtime accel: {}", GB.hexdump(partial)); + @Override + public void onMessage(final byte[] value) { + LOG.debug("Got realtime accel: {}", GB.hexdump(value)); + } } - private void processRealtimeSpo2(final ByteBuffer message) { - final int spo2 = message.get(); // -1 when unknown, and the ts is not valid in that case - final int garminTs = message.getInt(); + private static class RealtimeSpo2Callback implements ServiceCallback { + @Override + public void onMessage(final byte[] value) { + final int spo2 = value[0]; // -1 when unknown, and the ts is not valid in that case + final int garminTs = BLETypeConversions.toUint32(value, 1); - LOG.debug("Got realtime SpO2 at {}: {}", new Date(GarminTimeUtils.garminTimestampToJavaMillis(garminTs)), spo2); + LOG.debug("Got realtime SpO2 at {}: {}", new Date(GarminTimeUtils.garminTimestampToJavaMillis(garminTs)), spo2); + } } - private void processRealtimeRespiration(final ByteBuffer message) { - final int breathsPerMinute = message.get(); // can be negative if unknown, usually -2 + private static class RealtimeRespirationCallback implements ServiceCallback { + @Override + public void onMessage(final byte[] value) { + final int breathsPerMinute = value[0]; // can be negative if unknown, usually -2 - LOG.debug("Got realtime respiration: {}", breathsPerMinute); + LOG.debug("Got realtime respiration: {}", breathsPerMinute); + } } - private void processRealtimeHrv(final ByteBuffer message) { - final short rr = message.getShort(); - final int unk = message.getInt(); - - LOG.debug("Got realtime HRV: rr={}, unk={}", rr, unk); + private static class RealtimeHrvCallback implements ServiceCallback { + @Override + public void onMessage(final byte[] value) { + final int rr = BLETypeConversions.toUint16(value, 0); + final int unk = BLETypeConversions.toUint32(value, 2); + LOG.debug("Got realtime HRV: rr={}, unk={}", rr, unk); + } } private byte[] closeAllServices() { @@ -582,15 +607,25 @@ public class CommunicatorV2 implements ICommunicator { } public interface ServiceCallback { - void onConnect(ServiceWriter writer); - void onClose(); + default void onConnect(ServiceWriter writer) { + + } + + default void onClose() { + + } + void onMessage(byte[] value); } - public class ServiceWriter { + public interface ServiceWriter { + void write(String taskName, byte[] value); + } + + public class MlServiceWriter implements ServiceWriter { private final int handle; - private ServiceWriter(final int handle) { + private MlServiceWriter(final int handle) { this.handle = handle; } @@ -603,4 +638,24 @@ public class CommunicatorV2 implements ICommunicator { .queue(); } } + + private MlrCommunicator createMlrCommunicator(final int handle, final ServiceCallback callback) { + final MlrCommunicator.MessageSender messageSender = (taskName, packet) -> mSupport.createTransactionBuilder(taskName) + .write(characteristicSend, packet) + .queue(); + + return new MlrCommunicator(handle, maxWriteSize, messageSender, callback::onMessage); + } + + public static class MlrServiceWriter implements ServiceWriter { + private final MlrCommunicator mlrComm; + + private MlrServiceWriter(final MlrCommunicator mlrComm) { + this.mlrComm = mlrComm; + } + + public void write(final String taskName, final byte[] value) { + mlrComm.sendMessage(taskName, value); + } + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/v2/MlrCommunicator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/v2/MlrCommunicator.java new file mode 100644 index 0000000000..b4d8c4ae8f --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/v2/MlrCommunicator.java @@ -0,0 +1,312 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.v2; + +import android.bluetooth.BluetoothGatt; +import android.os.Handler; +import android.os.Looper; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Arrays; +import java.util.LinkedList; + +/** + * As per the docs. + */ +public class MlrCommunicator { + private final Logger LOG; + + public static final int MLR_FLAG_MASK = 0x80; + public static final int HANDLE_MASK = 0x70; + public static final int HANDLE_SHIFT = 4; + public static final int REQ_NUM_MASK = 0x0F; + public static final int SEQ_NUM_MASK = 0x3F; + + private static final int MAX_SEQ_NUM = 0x3F; + private static final int INITIAL_MAX_UNACKED_SEND = 0x20; + private static final int MAX_RETRANSMISSION_TIMEOUT = 20000; + private static final int INITIAL_RETRANSMISSION_TIMEOUT = 1000; + private static final int ACK_TIMEOUT = 250; + private static final int ACK_TRIGGER_THRESHOLD = 5; + + private final int handle; + private int maxPacketSize; + + private int lastSendAck = 0x00; + private int nextSendSeq = 0x00; + private int nextRcvSeq = 0x00; + private int lastRcvAck = 0x00; + private int maxNumUnackedSend = INITIAL_MAX_UNACKED_SEND; + private int retransmissionTimeout = INITIAL_RETRANSMISSION_TIMEOUT; + + private final LinkedList fragmentQueue = new LinkedList<>(); + private final Fragment[] sentFragments = new Fragment[MAX_SEQ_NUM + 1]; + + private final Handler timeoutHandler = new Handler(Looper.getMainLooper()); + private final Runnable ackRunnable = this::sendAckPacket; + private final Runnable retransmissionRunnable = this::onRetransmissionTimeout; + + private final MessageSender messageSender; + private final MessageReceiver messageReceiver; + + public MlrCommunicator(final int handle, + final int maxPacketSize, + final MessageSender messageSender, + final MessageReceiver messageReceiver) { + this.LOG = LoggerFactory.getLogger(MlrCommunicator.class.getName() + "(" + handle + ")"); + this.handle = handle; + this.maxPacketSize = maxPacketSize; + this.messageSender = messageSender; + this.messageReceiver = messageReceiver; + } + + public void setMaxPacketSize(final int maxPacketSize) { + this.maxPacketSize = maxPacketSize; + } + + public void sendMessage(final String taskName, final byte[] message) { + if (message == null || message.length == 0) { + return; + } + + LOG.debug( + "Queuing MLR message for '{}' ({} bytes)", + taskName, + message.length + ); + + int remainingBytes = message.length; + int i = 0; + if (remainingBytes > maxPacketSize - 2) { + int position = 0; + while (remainingBytes > 0) { + final byte[] fragment = Arrays.copyOfRange(message, position, position + Math.min(remainingBytes, maxPacketSize - 2)); + fragmentQueue.add(new Fragment(taskName, i++, fragment)); + position += fragment.length; + remainingBytes -= fragment.length; + } + } else { + fragmentQueue.add(new Fragment(taskName, 0, message)); + } + + runProtocol(); + } + + public void onPacketReceived(final byte[] packet) { + if (packet.length < 2) { + LOG.warn("MLR packet too short: {}", packet.length); + return; + } + + // MLR header + final int byte0 = packet[0] & 0xFF; + final int byte1 = packet[1] & 0xFF; + + if ((byte0 & MLR_FLAG_MASK) == 0) { + LOG.error("Received non-MLR packet"); + return; + } + + final int packetHandle = (byte0 & HANDLE_MASK) >> HANDLE_SHIFT; + final int reqNum = ((byte0 & REQ_NUM_MASK) << 2) | ((byte1 >> 6) & 0x03); + final int seqNum = byte1 & SEQ_NUM_MASK; + + if (packetHandle != (handle & 0x07)) { + LOG.error("MLR packet for wrong handle: expected {}, got {}", handle & 0x07, packetHandle); + return; + } + + LOG.debug( + "MLR packet received: reqNum={}, seqNum={}, dataLen={}", + reqNum, + seqNum, + packet.length - 2 + ); + + // Process ACK if request number changed + if (reqNum != lastRcvAck) { + processAck(reqNum); + } + + // Process data if any, and in sequence + if (packet.length > 2) { + if (seqNum == nextRcvSeq) { + // In-sequence packet + final byte[] data = Arrays.copyOfRange(packet, 2, packet.length); + try { + messageReceiver.onDataReceived(data); + } catch (final Exception e) { + LOG.error("Receiver failed to handle MLR data", e); + } + + nextRcvSeq = (nextRcvSeq + 1) % (MAX_SEQ_NUM + 1); + + scheduleAckIfNeeded(); + } else { + LOG.warn("Out-of-sequence packet - expected {}, got {}", nextRcvSeq, seqNum); + // Correct sequence will be retransmitted by sender + // Regardless, re-send the expected ack since the sender shouldn't be sending these + sendAckPacket(); + } + } + + runProtocol(); + } + + private void processAck(final int reqNum) { + final int numAcked = (reqNum - lastRcvAck + MAX_SEQ_NUM + 1) % (MAX_SEQ_NUM + 1); + final int numUnacked = (nextSendSeq - lastRcvAck + MAX_SEQ_NUM + 1) % (MAX_SEQ_NUM + 1); + + LOG.debug( + "Processing ACK: reqNum={}, numAcked={}, numUnacked={}, will expire fragments [{}, {}]", + reqNum, + numAcked, + numUnacked, + lastRcvAck, + reqNum - 1 + ); + + // Stop retransmission timer + timeoutHandler.removeCallbacks(retransmissionRunnable); + + // Remove acked messages from the array + for (int i = lastRcvAck; i != reqNum; i = (i + 1) % (MAX_SEQ_NUM + 1)) { + if (sentFragments[i] == null) { + LOG.error("Attempting to expire null fragment at index {}", i); + } + sentFragments[i] = null; + } + + lastRcvAck = reqNum; + + // Restart retransmission timer if there are still unacked packets + if (lastRcvAck != nextSendSeq) { + startRetransmissionTimer(); + } + } + + private void scheduleAckIfNeeded() { + timeoutHandler.removeCallbacks(ackRunnable); + + final int numRcvdUnacked = (nextRcvSeq - lastSendAck + MAX_SEQ_NUM + 1) % (MAX_SEQ_NUM + 1); + if (numRcvdUnacked >= ACK_TRIGGER_THRESHOLD) { + sendAckPacket(); + } else { + timeoutHandler.postDelayed(ackRunnable, ACK_TIMEOUT); + LOG.debug("Started ack timer: {}ms", ACK_TIMEOUT); + } + } + + private void sendAckPacket() { + timeoutHandler.removeCallbacks(ackRunnable); + + // Send ACK-only packet (no data) + byte[] packet = createPacket(nextRcvSeq, 0, new byte[0]); + messageSender.sendPacket("ack reqNum=" + nextRcvSeq, packet); + lastSendAck = nextRcvSeq; + LOG.debug("Sent ACK packet: reqNum={}", nextRcvSeq); + } + + private void runProtocol() { + // Check if we can send more packets + final int numSentUnacked = (nextSendSeq - lastRcvAck + MAX_SEQ_NUM + 1) % (MAX_SEQ_NUM + 1); + + if (numSentUnacked >= maxNumUnackedSend) { + LOG.debug("Cannot send more packets: {} unacked, max {}", numSentUnacked, maxNumUnackedSend); + return; + } + + // Send next fragment if available + final Fragment fragment = fragmentQueue.poll(); + if (fragment != null) { + final byte[] packet = createPacket(nextRcvSeq, nextSendSeq, fragment.data); + messageSender.sendPacket(fragment.taskName + " (" + fragment.num + ")", packet); + sentFragments[nextSendSeq] = fragment; + + nextSendSeq = (nextSendSeq + 1) % (MAX_SEQ_NUM + 1); + + // Start retransmission timer if this is the first unacked packet + if (numSentUnacked == 0) { + startRetransmissionTimer(); + } + + LOG.debug("Sent MLR packet: seqNum={}, dataLen={}", (nextSendSeq - 1 + MAX_SEQ_NUM + 1) % (MAX_SEQ_NUM + 1), fragment.data.length); + } + } + + private byte[] createPacket(final int reqNum, final int seqNum, final byte[] data) { + byte[] packet = new byte[2 + data.length]; + + // First byte: MLR flag (1) + handle (3 bits) + reqNum high bits (4 bits) + packet[0] = (byte) (MLR_FLAG_MASK | ((handle & 0x07) << HANDLE_SHIFT) | ((reqNum >> 2) & REQ_NUM_MASK)); + + // Second byte: reqNum low bits (2 bits) + seqNum (6 bits) + packet[1] = (byte) (((reqNum & 0x03) << 6) | (seqNum & SEQ_NUM_MASK)); + + // Data + System.arraycopy(data, 0, packet, 2, data.length); + + return packet; + } + + private void startRetransmissionTimer() { + timeoutHandler.removeCallbacks(retransmissionRunnable); + timeoutHandler.postDelayed(retransmissionRunnable, retransmissionTimeout); + + LOG.debug("Started retransmission timer: {}ms", retransmissionTimeout); + } + + private void onRetransmissionTimeout() { + LOG.debug("Retransmission timeout expired"); + + // Backoff retransmission timeout and reduce the maximum unacked + retransmissionTimeout = Math.min(retransmissionTimeout * 2, MAX_RETRANSMISSION_TIMEOUT); + maxNumUnackedSend = Math.max(1, maxNumUnackedSend / 2); + + LOG.debug( + "Retransmission: timeout={}ms, maxUnacked={}, will re-send fragments [{}, {}]", + retransmissionTimeout, + maxNumUnackedSend, + lastRcvAck, + nextSendSeq - 1 + ); + + for (int i = lastRcvAck; i != nextSendSeq; i = (i + 1) % (MAX_SEQ_NUM + 1)) { + LOG.debug("Re-sending fragment {}", i); + final Fragment fragment = sentFragments[i]; + if (fragment == null) { + LOG.error("Attempting to re-send null fragment at index {}", i); + continue; + } + final byte[] packet = createPacket(nextRcvSeq, i, fragment.data); + messageSender.sendPacket("retransmission " + fragment.taskName + " (" + fragment.num + ")", packet); + } + + startRetransmissionTimer(); + } + + public void close() { + LOG.debug("Closing MLR communicator"); + + timeoutHandler.removeCallbacksAndMessages(null); + + fragmentQueue.clear(); + } + + public void onConnectionStateChange(final BluetoothGatt gatt, final int status, final int newState) { + if (newState != BluetoothGatt.STATE_CONNECTED) { + timeoutHandler.removeCallbacksAndMessages(null); + } + } + + public interface MessageSender { + void sendPacket(String taskName, byte[] packet); + } + + public interface MessageReceiver { + void onDataReceived(final byte[] data); + } + + private record Fragment(String taskName, int num, byte[] data) { + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/GFDIMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/GFDIMessage.java index 1a91ee5778..ba195d94a6 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/GFDIMessage.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/GFDIMessage.java @@ -21,7 +21,7 @@ import nodomain.freeyourgadget.gadgetbridge.util.GB; public abstract class GFDIMessage { protected static final Logger LOG = LoggerFactory.getLogger(GFDIMessage.class); private static int maxPacketSize = 375; //safe default? - protected final ByteBuffer response = ByteBuffer.allocate(1000); + protected final ByteBuffer response = ByteBuffer.allocate(10 * 1024); // FIXME we should allocate the minimum necessary for each message protected GFDIStatusMessage statusMessage; protected GarminMessage garminMessage; @@ -131,15 +131,6 @@ public abstract class GFDIMessage { this.objectClass = objectClass; } - public static Class getClassFromId(final int id) { - for (final GarminMessage garminMessage : GarminMessage.values()) { - if (garminMessage.getId() == id) { - return garminMessage.getObjectClass(); - } - } - return null; - } - @Nullable public static GarminMessage fromId(final int id) { for (final GarminMessage garminMessage : GarminMessage.values()) { @@ -153,10 +144,6 @@ public abstract class GFDIMessage { public int getId() { return id; } - - private Class getObjectClass() { - return objectClass; - } } public enum Status { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6d5c11286d..94914e028b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4354,4 +4354,6 @@ Recovery Heart rate Send GPS and Time to device Send GPS and Time to device for weather and search stars optimization. Location should be set in the settings + Multi-Link Reliable Protocol + Experimental, might be unstable diff --git a/app/src/main/res/xml/devicesettings_garmin_mlr.xml b/app/src/main/res/xml/devicesettings_garmin_mlr.xml new file mode 100644 index 0000000000..476bba3ef4 --- /dev/null +++ b/app/src/main/res/xml/devicesettings_garmin_mlr.xml @@ -0,0 +1,10 @@ + + + +