Garmin: Initial MLR support

This commit is contained in:
José Rebelo
2025-10-02 22:40:10 +02:00
committed by José Rebelo
parent 36f5c8a31e
commit b352b164d4
10 changed files with 576 additions and 181 deletions
@@ -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;
}
@@ -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;
@@ -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
@@ -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();
@@ -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)
@@ -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<Service, Integer> handleByService = new HashMap<>();
private final Map<Service, ServiceCallback> 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<Integer, MlrCommunicator> 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);
}
}
}
@@ -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 <a href="https://gadgetbridge.org/internals/specifics/garmin-protocol/#multi-link-reliable-protocol">the docs.</a>
*/
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<Fragment> 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) {
}
}
@@ -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<? extends GFDIMessage> 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<? extends GFDIMessage> getObjectClass() {
return objectClass;
}
}
public enum Status {
+2
View File
@@ -4354,4 +4354,6 @@
<string name="recovery_heart_rate">Recovery Heart rate</string>
<string name="huawei_send_gps_and_time_title">Send GPS and Time to device</string>
<string name="huawei_send_gps_and_time_summary">Send GPS and Time to device for weather and search stars optimization. Location should be set in the settings</string>
<string name="garmin_mlr_protocol">Multi-Link Reliable Protocol</string>
<string name="feature_experimental_unstable">Experimental, might be unstable</string>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<SwitchPreferenceCompat
android:defaultValue="false"
android:icon="@drawable/ic_bolt"
android:key="garmin_mlr"
android:layout="@layout/preference_checkbox"
android:summary="@string/feature_experimental_unstable"
android:title="@string/garmin_mlr_protocol" />
</androidx.preference.PreferenceScreen>