Garmin: implement messages 5007/Filter and 5037/Synchronization

This commit does not override the default fetching of activity data, which is still phone-initiated, but adds support for watch-initiated sync as well (typically upon connecting).

Also change the order of the messages exchanged upon connection to mimic logs of official app.
Also remove some status messages where the outgoing message is already a status.
Also send the status "app in foreground" so that the watch can initiate the synchronization.
This commit is contained in:
Daniele Gobbetti
2025-12-31 18:54:13 +01:00
parent 5c9ae46e24
commit ab5999a480
8 changed files with 218 additions and 14 deletions
@@ -41,12 +41,15 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents.
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.CreateFileMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.DownloadRequestMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.FileTransferDataMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.FilterMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.GFDIMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.SynchronizationMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.SystemEventMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.UploadRequestMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.CreateFileStatusMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.DownloadRequestStatusMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.FileTransferDataStatusMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.FilterStatusMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.UploadRequestStatusMessage;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
@@ -94,6 +97,16 @@ public class FileTransferHandler implements MessageHandler {
return upload.setUploadRequestStatusMessage((UploadRequestStatusMessage) message);
else if (message instanceof FileTransferDataStatusMessage)
return upload.processUploadProgress((FileTransferDataStatusMessage) message);
else if (message instanceof SynchronizationMessage)
return processSynchronizationMessage((SynchronizationMessage) message);
else if (message instanceof FilterStatusMessage)
return initiateDownload();
return null;
}
private FilterMessage processSynchronizationMessage(SynchronizationMessage message) {
if (message.shouldProceed())
return new FilterMessage();
return null;
}
@@ -110,6 +110,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.SetD
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.SetFileFlagsMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.SupportedFileTypesMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.SystemEventMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.GenericStatusMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.NotificationSubscriptionStatusMessage;
import nodomain.freeyourgadget.gadgetbridge.util.ArrayUtils;
import nodomain.freeyourgadget.gadgetbridge.util.CompressionUtils;
@@ -295,8 +296,7 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
return; //message cannot be handled
}
LOG.debug("Got GFDIMessage {} ({} bytes)", parsedMessage.getClass().getSimpleName(), message.length);
LOG.debug("INCOMING message: {}/{}: {}", parsedMessage, parsedMessage.getGarminMessage(), GB.hexdump(message));
/*
the handler elaborates the followup message but might change the status message since it does
check the integrity of the incoming message payload. Hence we let the handlers elaborate the
@@ -317,13 +317,13 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
evaluateGBDeviceEvent(event);
}
communicator.sendMessage("send status", parsedMessage.getAckBytestream()); //send status message
sendAck("send status", parsedMessage); //send status message
sendOutgoingMessage("send reply", parsedMessage); //send reply if any
sendOutgoingMessage("send followup", followup); //send followup message if any
if (parsedMessage instanceof ConfigurationMessage) { //the last forced message exchange
if (parsedMessage instanceof GenericStatusMessage && ((GenericStatusMessage) parsedMessage).getGarminMessage() == GFDIMessage.GarminMessage.CONFIGURATION) { //the last forced message exchange
completeInitialization();
}
@@ -477,6 +477,10 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
// We initiate download here even in the new sync protocol so that the watch "flushes" the data
// otherwise we might get incomplete monitor files
sendOutgoingMessage("fetch recorded data", fileTransferHandler.initiateDownload());
//TODO: ask the watch to initiate the sync? Something like:
// sendOutgoingMessage("set sync ready", new SystemEventMessage(SystemEventMessage.GarminSystemEventType.SYNC_READY, 0));
// sendOutgoingMessage("set foreground", new SystemEventMessage(SystemEventMessage.GarminSystemEventType.HOST_DID_ENTER_FOREGROUND, 0));
}
public boolean newSyncProtocol() {
@@ -598,9 +602,19 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
private void sendOutgoingMessage(final String taskName, final GFDIMessage message) {
if (message == null)
return;
if (message.getOutgoingMessage() != null)
LOG.debug("OUTGOING message {}: {}", message, GB.hexdump(message.getOutgoingMessage()));
communicator.sendMessage(taskName, message.getOutgoingMessage());
}
private void sendAck(final String taskName, final GFDIMessage message) {
if (message == null)
return;
if (message.getAckBytestream() != null)
LOG.debug("OUTGOING ACK {}: {}", message, GB.hexdump(message.getAckBytestream()));
communicator.sendMessage(taskName, message.getAckBytestream());
}
private void sendWeatherConditions(WeatherSpec weather) {
if (!getCoordinator().supports(getDevice(), GarminCapability.WEATHER_CONDITIONS)) {
// Device does not support sending weather as fit
@@ -708,19 +722,23 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
}
private void completeInitialization() {
sendOutgoingMessage("request supported file types", new SupportedFileTypesMessage());
sendDeviceSettings();
if (GBApplication.getPrefs().syncTime()) {
onSetTime();
}
enableWeather();
//following is needed for vivomove style
sendOutgoingMessage("set sync ready", new SystemEventMessage(SystemEventMessage.GarminSystemEventType.SYNC_READY, 0));
enableBatteryLevelUpdate();
sendOutgoingMessage("set foreground", new SystemEventMessage(SystemEventMessage.GarminSystemEventType.HOST_DID_ENTER_FOREGROUND, 0));
gbDevice.setUpdateState(GBDevice.State.INITIALIZED, getContext());
gbDevice.sendDeviceUpdateIntent(getContext());
sendOutgoingMessage("request supported file types", new SupportedFileTypesMessage());
if (mFirstConnect) {
sendOutgoingMessage("set sync complete", new SystemEventMessage(SystemEventMessage.GarminSystemEventType.SYNC_COMPLETE, 0));
@@ -833,6 +851,10 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
getDevice().sendDeviceUpdateIntent(getContext());
}
isBusyFetching = false;
sendOutgoingMessage("set sync complete", new SystemEventMessage(SystemEventMessage.GarminSystemEventType.SYNC_COMPLETE, 0));
sendOutgoingMessage("set background", new SystemEventMessage(SystemEventMessage.GarminSystemEventType.HOST_DID_ENTER_BACKGROUND, 0));
return;
}
@@ -855,6 +877,7 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
GB.signalActivityDataFinish(getDevice());
transferNotification.finish();
getDevice().sendDeviceUpdateIntent(getContext());
isBusyFetching = false;
}
});
}
@@ -872,12 +895,12 @@ public class GarminSupport extends AbstractBTLESingleDeviceSupport implements IC
sendOutgoingMessage("enable battery updates", batteryLevelProtobufRequest);
}
private void enableWeather() {
private void sendDeviceSettings() {
final Map<SetDeviceSettingsMessage.GarminDeviceSetting, Object> settings = new LinkedHashMap<>(3);
settings.put(SetDeviceSettingsMessage.GarminDeviceSetting.AUTO_UPLOAD_ENABLED, false);
settings.put(SetDeviceSettingsMessage.GarminDeviceSetting.AUTO_UPLOAD_ENABLED, true);
settings.put(SetDeviceSettingsMessage.GarminDeviceSetting.WEATHER_CONDITIONS_ENABLED, true);
settings.put(SetDeviceSettingsMessage.GarminDeviceSetting.WEATHER_ALERTS_ENABLED, false);
sendOutgoingMessage("enable weather", new SetDeviceSettingsMessage(settings));
sendOutgoingMessage("send device settings", new SetDeviceSettingsMessage(settings));
}
@Override
@@ -27,8 +27,9 @@ public class DeviceInformationMessage extends GFDIMessage {
private final String deviceName;
private final String deviceModel;
// dual-pairing flags & MAC addresses...
private final boolean generateOutgoing;
public DeviceInformationMessage(GarminMessage garminMessage, int protocolVersion, int productNumber, String unitNumber, int softwareVersion, int maxPacketSize, String bluetoothFriendlyName, String deviceName, String deviceModel) {
public DeviceInformationMessage(GarminMessage garminMessage, int protocolVersion, int productNumber, String unitNumber, int softwareVersion, int maxPacketSize, String bluetoothFriendlyName, String deviceName, String deviceModel, boolean generateOutgoing) {
this.garminMessage = garminMessage;
this.incomingProtocolVersion = protocolVersion;
this.incomingProductNumber = productNumber;
@@ -41,6 +42,11 @@ public class DeviceInformationMessage extends GFDIMessage {
GFDIMessage.setMaxPacketSize(maxPacketSize);
this.statusMessage = getStatusMessage();
this.generateOutgoing = generateOutgoing;
}
public DeviceInformationMessage(GarminMessage garminMessage, int protocolVersion, int productNumber, String unitNumber, int softwareVersion, int maxPacketSize, String bluetoothFriendlyName, String deviceName, String deviceModel) {
this(garminMessage, protocolVersion, productNumber, unitNumber, softwareVersion, maxPacketSize, bluetoothFriendlyName, deviceName, deviceModel, false);
}
public static DeviceInformationMessage parseIncoming(MessageReader reader, GarminMessage garminMessage) {
@@ -82,7 +88,7 @@ public class DeviceInformationMessage extends GFDIMessage {
writer.writeString(Build.MANUFACTURER);
writer.writeString(Build.DEVICE);
writer.writeByte(protocolFlags);
return true;
return this.generateOutgoing;
}
@Override
@@ -0,0 +1,26 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
public class FilterMessage extends GFDIMessage {
public FilterMessage() {
this.garminMessage = GarminMessage.FILTER;
}
@Override
protected boolean generateOutgoing() {
final MessageWriter writer = new MessageWriter(response);
writer.writeShort(0); // packet size will be filled below
writer.writeShort(this.garminMessage.getId());
writer.writeByte(FilterType.UNK_3.ordinal());
return true;
}
public enum FilterType {
NO_0,
UNK_1,
UNK_2,
UNK_3
}
}
@@ -58,6 +58,10 @@ public abstract class GFDIMessage {
}
}
public GarminMessage getGarminMessage() {
return garminMessage;
}
protected abstract boolean generateOutgoing();
public byte[] getOutgoingMessage() {
@@ -100,6 +104,7 @@ public abstract class GFDIMessage {
UPLOAD_REQUEST(5003, UploadRequestMessage.class),
FILE_TRANSFER_DATA(5004, FileTransferDataMessage.class),
CREATE_FILE(5005, CreateFileMessage.class),
FILTER(5007, FilterMessage.class),
SET_FILE_FLAG(5008, SetFileFlagsMessage.class),
FIT_DEFINITION(5011, FitDefinitionMessage.class),
FIT_DATA(5012, FitDataMessage.class),
@@ -112,6 +117,7 @@ public abstract class GFDIMessage {
NOTIFICATION_CONTROL(5034, NotificationControlMessage.class),
NOTIFICATION_DATA(5035, NotificationDataMessage.class),
NOTIFICATION_SUBSCRIPTION(5036, NotificationSubscriptionMessage.class),
SYNCHRONIZATION(5037, SynchronizationMessage.class),
FIND_MY_PHONE_REQUEST(5039, FindMyPhoneRequestMessage.class),
FIND_MY_PHONE_CANCEL(5040, FindMyPhoneCancelMessage.class),
MUSIC_CONTROL(5041, MusicControlMessage.class),
@@ -121,8 +127,7 @@ public abstract class GFDIMessage {
MUSIC_CONTROL_ENTITY_UPDATE(5049, MusicControlEntityUpdateMessage.class),
CONFIGURATION(5050, ConfigurationMessage.class),
CURRENT_TIME_REQUEST(5052, CurrentTimeRequestMessage.class),
AUTH_NEGOTIATION(5101, AuthNegotiationMessage.class)
;
AUTH_NEGOTIATION(5101, AuthNegotiationMessage.class);
private final Class<? extends GFDIMessage> objectClass;
private final int id;
@@ -0,0 +1,105 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
import org.apache.commons.lang3.EnumUtils;
import java.util.EnumSet;
public class SynchronizationMessage extends GFDIMessage {
private final SynchronizationType synchronizationType;
private final EnumSet<FileType> syncBitmask;
public SynchronizationMessage(GarminMessage garminMessage, SynchronizationType synchronizationType, EnumSet<FileType> bitmask) {
this.garminMessage = garminMessage;
this.synchronizationType = synchronizationType;
this.syncBitmask = bitmask;
this.statusMessage = super.getStatusMessage();
LOG.debug("type: {}, bitmask: {}", synchronizationType, bitmask);
}
public static SynchronizationMessage parseIncoming(MessageReader reader, GarminMessage garminMessage) {
final int type = reader.readByte();
final SynchronizationType synchronizationType = SynchronizationType.fromCode(type);
final int size = reader.readByte();
final long bitmask;
if (size == 8) {
bitmask = reader.readLong();
} else if (size == 4) {
bitmask = reader.readInt();
} else {
LOG.warn("SynchronizationMessage bitmask size unexpected, was: {}", size);
return null;
}
final EnumSet<FileType> syncBitmask = EnumUtils.processBitVector(FileType.class, bitmask);
return new SynchronizationMessage(garminMessage, synchronizationType, syncBitmask);
}
public boolean shouldProceed() {
return syncBitmask.contains(FileType.WORKOUTS) || syncBitmask.contains(FileType.ACTIVITIES)
|| syncBitmask.contains(FileType.ACTIVITY_SUMMARY) || syncBitmask.contains(FileType.SLEEP);
}
@Override
protected boolean generateOutgoing() {
return false;
}
public enum SynchronizationType {
TYPE_0,
TYPE_1,
TYPE_2,
;
public static SynchronizationType fromCode(final int code) {
for (final SynchronizationType type : SynchronizationType.values()) {
if (type.ordinal() == code) {
return type;
}
}
throw new IllegalArgumentException("Unknown synchronization type " + code);
}
}
public enum FileType {
unk_0,
unk_1,
unk_2,
WORKOUTS,
unk_4,
ACTIVITIES,
unk_6,
unk_7,
SOFTWARE_UPDATE,
unk_9,
unk_10,
unk_11,
unk_12,
unk_13,
unk_14,
unk_15,
unk_16,
unk_17,
unk_18,
unk_19,
unk_20,
ACTIVITY_SUMMARY,
unk_22,
unk_23,
unk_24,
unk_25,
SLEEP,
unk_27,
unk_28,
unk_29,
unk_30,
unk_31,
unk_32,
unk_33,
unk_34,
unk_35,
unk_36,
;
}
}
@@ -0,0 +1,24 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status;
public class FilterStatusMessage extends GFDIStatusMessage {
private final Status status;
public FilterStatusMessage(GarminMessage garminMessage, Status status, int unk) {
this.garminMessage = garminMessage;
this.status = status;
LOG.info("Received ACK for message {}", garminMessage);
}
public static FilterStatusMessage parseIncoming(MessageReader reader, GarminMessage garminMessage) {
final Status status = Status.fromCode(reader.readByte());
if (!status.equals(Status.ACK)) {
return null;
}
final int unk = reader.readByte();
return new FilterStatusMessage(garminMessage, status, unk);
}
}
@@ -34,6 +34,8 @@ public abstract class GFDIStatusMessage extends GFDIMessage {
return FitDataStatusMessage.parseIncoming(reader, originalGarminMessage);
} else if (GarminMessage.AUTH_NEGOTIATION.equals(originalGarminMessage)) {
return AuthNegotiationStatusMessage.parseIncoming(reader, originalGarminMessage);
} else if (GarminMessage.FILTER.equals(originalGarminMessage)) {
return FilterStatusMessage.parseIncoming(reader, originalGarminMessage);
} else {
final Status status = Status.fromCode(reader.readByte());