From 4363f94661c3d8b40e780edd281bbbcb41b9a905 Mon Sep 17 00:00:00 2001 From: Daniele Gobbetti Date: Mon, 11 Mar 2024 19:11:09 +0100 Subject: [PATCH] Garmin protocol: initial refactoring and basic functionalities This commit takes aims to bring many new garmin devices up to a working status, with basic functionalities such as: - garmin protocol initialization - basic message exchange - support for some messages in Garmin own format - support for some messages in protobuf format --- .../GarminInstinct2SCoordinator.java | 47 +++ .../garmin/venu3/GarminVenu3Coordinator.java | 42 +++ .../GarminVivomoveStyleCoordinator.java | 48 +++ .../vivomovehr/VivomoveHrCoordinator.java | 2 +- .../gadgetbridge/model/DeviceType.java | 12 +- .../service/devices/garmin/GarminSupport.java | 253 +++++++++++++++ .../devices/garmin/ProtocolBufferHandler.java | 294 ++++++++++++++++++ .../garmin/communicator/CobsCoDec.java | 134 ++++++++ .../garmin/communicator/ICommunicator.java | 18 ++ .../communicator/v1/CommunicatorV1.java | 36 +++ .../communicator/v2/CommunicatorV2.java | 173 +++++++++++ .../garmin/messages/ChecksumCalculator.java | 47 +++ .../garmin/messages/ConfigurationMessage.java | 43 +++ .../messages/CurrentTimeRequestMessage.java | 52 ++++ .../messages/DeviceInformationMessage.java | 91 ++++++ .../messages/FindMyPhoneRequestMessage.java | 34 ++ .../devices/garmin/messages/GFDIMessage.java | 159 ++++++++++ .../garmin/messages/GFDIStatusMessage.java | 28 ++ .../garmin/messages/GenericStatusMessage.java | 23 ++ .../garmin/messages/MessageReader.java | 128 ++++++++ .../garmin/messages/MessageWriter.java | 87 ++++++ .../MusicControlCapabilitiesMessage.java | 50 +++ .../MusicControlEntityUpdateMessage.java | 80 +++++ .../garmin/messages/MusicControlMessage.java | 45 +++ .../garmin/messages/ProtobufMessage.java | 89 ++++++ .../messages/ProtobufStatusMessage.java | 115 +++++++ .../messages/SetDeviceSettingsMessage.java | 52 ++++ .../garmin/messages/SystemEventMessage.java | 48 +++ .../garmin/messages/UnhandledMessage.java | 18 ++ .../util/calendar/CalendarEvent.java | 4 + .../gdi_calendar_service.proto | 29 ++ .../proto/garmin_vivomovehr/gdi_core.proto | 52 +++- .../garmin_vivomovehr/gdi_smart_proto.proto | 2 + app/src/main/res/values/strings.xml | 4 +- .../devices/garmin/GarminSupportTest.java | 100 ++++++ 35 files changed, 2425 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/instinct2s/GarminInstinct2SCoordinator.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/venu3/GarminVenu3Coordinator.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/vivomove/GarminVivomoveStyleCoordinator.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/ProtocolBufferHandler.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/CobsCoDec.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/ICommunicator.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/v1/CommunicatorV1.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/v2/CommunicatorV2.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/ChecksumCalculator.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/ConfigurationMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/CurrentTimeRequestMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/DeviceInformationMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/FindMyPhoneRequestMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/GFDIMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/GFDIStatusMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/GenericStatusMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/MessageReader.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/MessageWriter.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/MusicControlCapabilitiesMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/MusicControlEntityUpdateMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/MusicControlMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/ProtobufMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/ProtobufStatusMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/SetDeviceSettingsMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/SystemEventMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/UnhandledMessage.java create mode 100644 app/src/main/proto/garmin_vivomovehr/gdi_calendar_service.proto create mode 100644 app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupportTest.java diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/instinct2s/GarminInstinct2SCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/instinct2s/GarminInstinct2SCoordinator.java new file mode 100644 index 000000000..652a810d8 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/instinct2s/GarminInstinct2SCoordinator.java @@ -0,0 +1,47 @@ +package nodomain.freeyourgadget.gadgetbridge.devices.garmin.instinct2s; + +import androidx.annotation.NonNull; + +import java.util.regex.Pattern; + +import nodomain.freeyourgadget.gadgetbridge.GBException; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.Device; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupport; + +public class GarminInstinct2SCoordinator extends AbstractBLEDeviceCoordinator { + @Override + protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException { + + } + + @Override + protected Pattern getSupportedDeviceName() { + return Pattern.compile("Instinct 2S"); + } + + @Override + public String getManufacturer() { + return "Garmin"; + } + + @NonNull + @Override + public Class getDeviceSupportClass() { + return GarminSupport.class; + } + + @Override + public int getDeviceNameResource() { + return R.string.devicetype_garmin_instinct_2s; + } + + @Override + public boolean supportsFindDevice() { + return true; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/venu3/GarminVenu3Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/venu3/GarminVenu3Coordinator.java new file mode 100644 index 000000000..3a17c353a --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/venu3/GarminVenu3Coordinator.java @@ -0,0 +1,42 @@ +package nodomain.freeyourgadget.gadgetbridge.devices.garmin.venu3; + +import androidx.annotation.NonNull; + +import java.util.regex.Pattern; + +import nodomain.freeyourgadget.gadgetbridge.GBException; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.Device; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupport; + +public class GarminVenu3Coordinator extends AbstractBLEDeviceCoordinator { + @Override + protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException { + + } + + @Override + protected Pattern getSupportedDeviceName() { + return Pattern.compile("Venu 3"); + } + + @Override + public String getManufacturer() { + return "Garmin"; + } + + @NonNull + @Override + public Class getDeviceSupportClass() { + return GarminSupport.class; + } + + @Override + public int getDeviceNameResource() { + return R.string.devicetype_garmin_vivomove_style; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/vivomove/GarminVivomoveStyleCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/vivomove/GarminVivomoveStyleCoordinator.java new file mode 100644 index 000000000..a7f6db6e0 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/vivomove/GarminVivomoveStyleCoordinator.java @@ -0,0 +1,48 @@ +package nodomain.freeyourgadget.gadgetbridge.devices.garmin.vivomove; + +import androidx.annotation.NonNull; + +import java.util.regex.Pattern; + +import nodomain.freeyourgadget.gadgetbridge.GBException; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.Device; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupport; + +public class GarminVivomoveStyleCoordinator extends AbstractBLEDeviceCoordinator { + @Override + protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException { + + } + + @Override + protected Pattern getSupportedDeviceName() { + return Pattern.compile("vívomove Style"); + } + + @Override + public String getManufacturer() { + return "Garmin"; + } + + @NonNull + @Override + public Class getDeviceSupportClass() { + return GarminSupport.class; + } + + @Override + public int getDeviceNameResource() { + return R.string.devicetype_garmin_vivomove_style; + } + + @Override + public boolean supportsFindDevice() { + return true; + } + +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vivomovehr/VivomoveHrCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vivomovehr/VivomoveHrCoordinator.java index 9b6076285..121dc6bf7 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vivomovehr/VivomoveHrCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vivomovehr/VivomoveHrCoordinator.java @@ -143,7 +143,7 @@ public class VivomoveHrCoordinator extends AbstractBLEDeviceCoordinator { @Override public int getDeviceNameResource() { - return R.string.devicetype_vivomove_hr; + return R.string.devicetype_garmin_vivomove_hr; } @Override diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java index a65e963b4..574f3bc65 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java @@ -49,6 +49,9 @@ import nodomain.freeyourgadget.gadgetbridge.devices.galaxy_buds.GalaxyBuds2ProDe import nodomain.freeyourgadget.gadgetbridge.devices.galaxy_buds.GalaxyBudsDeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.galaxy_buds.GalaxyBudsLiveDeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.galaxy_buds.GalaxyBudsProDeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.garmin.instinct2s.GarminInstinct2SCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.garmin.venu3.GarminVenu3Coordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.garmin.vivomove.GarminVivomoveStyleCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.hplus.EXRIZUK8Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.hplus.HPlusCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.hplus.MakibesF68Coordinator; @@ -137,9 +140,9 @@ import nodomain.freeyourgadget.gadgetbridge.devices.lenovo.watchxplus.WatchXPlus import nodomain.freeyourgadget.gadgetbridge.devices.liveview.LiveviewCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.makibeshr3.MakibesHR3Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandCoordinator; -import nodomain.freeyourgadget.gadgetbridge.devices.mijia_lywsd.MijiaMhoC303Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.mijia_lywsd.MijiaLywsd02Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.mijia_lywsd.MijiaLywsd03Coordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.mijia_lywsd.MijiaMhoC303Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.miscale2.MiScale2DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.no1f1.No1F1Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.nothing.Ear1Coordinator; @@ -161,11 +164,11 @@ import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWF1000XM4Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWF1000XM5Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWFSP800NCoordinator; -import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWISP600NCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWH1000XM2Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWH1000XM3Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWH1000XM4Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWH1000XM5Coordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWISP600NCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.sony.wena3.SonyWena3Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.sonyswr12.SonySWR12DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.supercars.SuperCarsCoordinator; @@ -182,6 +185,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miband7pro.MiBand7Pro import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miband8.MiBand8Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miband8active.MiBand8ActiveCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miband8pro.MiBand8ProCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miwatch.MiWatchLiteCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miwatchcolorsport.MiWatchColorSportCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmismartband2.RedmiSmartBand2Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmismartbandpro.RedmiSmartBandProCoordinator; @@ -195,7 +199,6 @@ import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.watchs1pro.XiaomiWatc import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.watchs3.XiaomiWatchS3Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.xwatch.XWatchCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.zetime.ZeTimeCoordinator; -import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miwatch.MiWatchLiteCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmiwatch3active.RedmiWatch3ActiveCoordinator; /** @@ -322,6 +325,9 @@ public enum DeviceType { ITAG(ITagCoordinator.class), NUTMINI(NutCoordinator.class), VIVOMOVE_HR(VivomoveHrCoordinator.class), + GARMIN_INSTINCT_2S(GarminInstinct2SCoordinator.class), + GARMIN_VIVOMOVE_STYLE(GarminVivomoveStyleCoordinator.class), + GARMIN_VENU_3(GarminVenu3Coordinator.class), VIBRATISSIMO(VibratissimoCoordinator.class), SONY_SWR12(SonySWR12DeviceCoordinator.class), LIVEVIEW(LiveviewCoordinator.class), 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 new file mode 100644 index 000000000..d123556c4 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java @@ -0,0 +1,253 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin; + +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCharacteristic; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.text.DecimalFormat; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Timer; +import java.util.TimerTask; +import java.util.UUID; + +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.MusicSpec; +import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec; +import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiDeviceStatus; +import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiFindMyWatch; +import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiSmartProto; +import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.ICommunicator; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.v1.CommunicatorV1; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.v2.CommunicatorV2; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.ConfigurationMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.GFDIMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.MusicControlEntityUpdateMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.ProtobufMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.ProtobufStatusMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.SetDeviceSettingsMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.SystemEventMessage; +import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; + + +public class GarminSupport extends AbstractBTLEDeviceSupport implements ICommunicator.Callback { + private static final Logger LOG = LoggerFactory.getLogger(GarminSupport.class); + private final ProtocolBufferHandler protocolBufferHandler; + private ICommunicator communicator; + private MusicStateSpec musicStateSpec; + private Timer musicStateTimer; + + public GarminSupport() { + super(LOG); + addSupportedService(CommunicatorV1.UUID_SERVICE_GARMIN_GFDI); + addSupportedService(CommunicatorV2.UUID_SERVICE_GARMIN_ML_GFDI); + protocolBufferHandler = new ProtocolBufferHandler(this); + } + + @Override + public boolean useAutoConnect() { + return false; + } + + @Override + protected TransactionBuilder initializeDevice(final TransactionBuilder builder) { + builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext())); + + if (getSupportedServices().contains(CommunicatorV2.UUID_SERVICE_GARMIN_ML_GFDI)) { + communicator = new CommunicatorV2(this); + } else if (getSupportedServices().contains(CommunicatorV1.UUID_SERVICE_GARMIN_GFDI)) { + communicator = new CommunicatorV1(this); + } else { + LOG.warn("Failed to find a known Garmin service"); + builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.NOT_CONNECTED, getContext())); + return builder; + } + + communicator.initializeDevice(builder); + + return builder; + } + + @Override + public boolean onCharacteristicChanged(final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic) { + final UUID characteristicUUID = characteristic.getUuid(); + if (super.onCharacteristicChanged(gatt, characteristic)) { + LOG.debug("Change of characteristic {} handled by parent", characteristicUUID); + return true; + } + + return communicator.onCharacteristicChanged(gatt, characteristic); + } + + @Override + public void onMessage(final byte[] message) { + if (null == message) { + return; //message is not complete yet TODO check before calling + } +// LOG.debug("COBS decoded MESSAGE: {}", GB.hexdump(message)); + + GFDIMessage parsedMessage = GFDIMessage.parseIncoming(message); + + if (null == parsedMessage) { + return; //message cannot be handled + } + + evaluateGBDeviceEvent(parsedMessage.getGBDeviceEvent()); + + communicator.sendMessage(parsedMessage.getAckBytestream()); + + byte[] response = parsedMessage.getOutgoingMessage(); + if (null != response) { +// LOG.debug("sending response {}", GB.hexdump(response)); + communicator.sendMessage(response); + } + + if (parsedMessage instanceof ConfigurationMessage) { //the last forced message exchange + completeInitialization(); + } + + if (parsedMessage instanceof ProtobufMessage) { + ProtobufMessage protobufMessage = protocolBufferHandler.processIncoming((ProtobufMessage) parsedMessage); + if (protobufMessage != null) { + communicator.sendMessage(protobufMessage.getOutgoingMessage()); + communicator.sendMessage(protobufMessage.getAckBytestream()); + } + } + + if (parsedMessage instanceof ProtobufStatusMessage) { + ProtobufMessage protobufMessage = protocolBufferHandler.processIncoming((ProtobufStatusMessage) parsedMessage); + if (protobufMessage != null) { + communicator.sendMessage(protobufMessage.getOutgoingMessage()); + communicator.sendMessage(protobufMessage.getAckBytestream()); + } + } + } + + private void completeInitialization() { + + enableWeather(); + + onSetTime(); + + //following is needed for vivomove style + communicator.sendMessage(new SystemEventMessage(SystemEventMessage.GarminSystemEventType.SYNC_READY, 0).getOutgoingMessage()); + + enableBatteryLevelUpdate(); + + gbDevice.setState(GBDevice.State.INITIALIZED); + gbDevice.sendDeviceUpdateIntent(getContext()); + + } + + private void enableBatteryLevelUpdate() { + final ProtobufMessage batteryLevelProtobufRequest = protocolBufferHandler.prepareProtobufRequest(GdiSmartProto.Smart.newBuilder() + .setDeviceStatusService( + GdiDeviceStatus.DeviceStatusService.newBuilder() + .setRemoteDeviceBatteryStatusRequest( + GdiDeviceStatus.DeviceStatusService.RemoteDeviceBatteryStatusRequest.newBuilder() + ) + ) + .build()); + communicator.sendMessage(batteryLevelProtobufRequest.getOutgoingMessage()); + } + + private void enableWeather() { + final Map settings = new LinkedHashMap<>(2); + settings.put(SetDeviceSettingsMessage.GarminDeviceSetting.WEATHER_CONDITIONS_ENABLED, true); + settings.put(SetDeviceSettingsMessage.GarminDeviceSetting.WEATHER_ALERTS_ENABLED, true); + communicator.sendMessage(new SetDeviceSettingsMessage(settings).getOutgoingMessage()); + } + + @Override + public void onSetTime() { + communicator.sendMessage(new SystemEventMessage(SystemEventMessage.GarminSystemEventType.TIME_UPDATED, 0).getOutgoingMessage()); + } + + @Override + public void onFindDevice(boolean start) { + if (start) { + final ProtobufMessage findMyWatch = protocolBufferHandler.prepareProtobufRequest( + GdiSmartProto.Smart.newBuilder() + .setFindMyWatchService( + GdiFindMyWatch.FindMyWatchService.newBuilder() + .setFindRequest( + GdiFindMyWatch.FindMyWatchService.FindMyWatchRequest.newBuilder() + .setTimeout(60) + ) + ) + .build()); + communicator.sendMessage(findMyWatch.getOutgoingMessage()); + } else { + final ProtobufMessage cancelFindMyWatch = protocolBufferHandler.prepareProtobufRequest( + GdiSmartProto.Smart.newBuilder() + .setFindMyWatchService( + GdiFindMyWatch.FindMyWatchService.newBuilder() + .setCancelRequest( + GdiFindMyWatch.FindMyWatchService.FindMyWatchCancelRequest.newBuilder() + ) + ) + .build()); + communicator.sendMessage(cancelFindMyWatch.getOutgoingMessage()); + } + } + + @Override + public void onSetMusicInfo(MusicSpec musicSpec) { + + Map attributes = new HashMap<>(); + + attributes.put(MusicControlEntityUpdateMessage.TRACK.ARTIST, musicSpec.artist); + attributes.put(MusicControlEntityUpdateMessage.TRACK.ALBUM, musicSpec.album); + attributes.put(MusicControlEntityUpdateMessage.TRACK.TITLE, musicSpec.track); + attributes.put(MusicControlEntityUpdateMessage.TRACK.DURATION, String.valueOf(musicSpec.duration)); + + communicator.sendMessage(new MusicControlEntityUpdateMessage(attributes).getOutgoingMessage()); + } + + @Override + public void onSetMusicState(MusicStateSpec stateSpec) { + musicStateSpec = stateSpec; + + if (musicStateTimer != null) { + musicStateTimer.cancel(); + musicStateTimer.purge(); + musicStateTimer = null; + } + + musicStateTimer = new Timer(); + int updatePeriod = 4000; //milliseconds + LOG.debug("onSetMusicState: {}", stateSpec.toString()); + + if (stateSpec.state == MusicStateSpec.STATE_PLAYING) { + musicStateTimer.schedule(new TimerTask() { + @Override + public void run() { + String playing = "1"; + String playRate = "1.0"; + String position = new DecimalFormat("#.000").format(musicStateSpec.position); + musicStateSpec.position += updatePeriod / 1000; + + Map attributes = new HashMap<>(); + attributes.put(MusicControlEntityUpdateMessage.PLAYER.PLAYBACK_INFO, StringUtils.join(",", playing, playRate, position).toString()); + communicator.sendMessage(new MusicControlEntityUpdateMessage(attributes).getOutgoingMessage()); + + } + }, 0, updatePeriod); + } else { + String playing = "0"; + String playRate = "0.0"; + String position = new DecimalFormat("#.###").format(stateSpec.position); + + Map attributes = new HashMap<>(); + attributes.put(MusicControlEntityUpdateMessage.PLAYER.PLAYBACK_INFO, StringUtils.join(",", playing, playRate, position).toString()); + communicator.sendMessage(new MusicControlEntityUpdateMessage(attributes).getOutgoingMessage()); + } + } + +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/ProtocolBufferHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/ProtocolBufferHandler.java new file mode 100644 index 000000000..5f84e9259 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/ProtocolBufferHandler.java @@ -0,0 +1,294 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin; + +import com.google.protobuf.InvalidProtocolBufferException; + +import org.apache.commons.lang3.ArrayUtils; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo; +import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiCalendarService; +import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiCore; +import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiDeviceStatus; +import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiFindMyWatch; +import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiSmartProto; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.GFDIMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.ProtobufMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.ProtobufStatusMessage; +import nodomain.freeyourgadget.gadgetbridge.util.GB; +import nodomain.freeyourgadget.gadgetbridge.util.calendar.CalendarEvent; +import nodomain.freeyourgadget.gadgetbridge.util.calendar.CalendarManager; + +public class ProtocolBufferHandler { + + private static final Logger LOG = LoggerFactory.getLogger(ProtocolBufferHandler.class); + private final GarminSupport deviceSupport; + private final Map chunkedFragmentsMap; + private final int maxChunkSize = 375; //tested on Vívomove Style + private int lastProtobufRequestId; + + public ProtocolBufferHandler(GarminSupport deviceSupport) { + this.deviceSupport = deviceSupport; + chunkedFragmentsMap = new HashMap<>(); + } + + private int getNextProtobufRequestId() { + lastProtobufRequestId = (lastProtobufRequestId + 1) % 65536; + return lastProtobufRequestId; + } + + ProtobufMessage processIncoming(ProtobufMessage message) { + ProtobufFragment protobufFragment = processChunkedMessage(message); + + if (protobufFragment.isComplete()) { //message is now complete + LOG.info("Received protobuf message #{}, {}B: {}", message.getRequestId(), protobufFragment.totalLength, GB.hexdump(protobufFragment.fragmentBytes, 0, protobufFragment.totalLength)); + + final GdiSmartProto.Smart smart; + try { + smart = GdiSmartProto.Smart.parseFrom(protobufFragment.fragmentBytes); + } catch (InvalidProtocolBufferException e) { + LOG.error("Failed to parse protobuf message ({}): {}", e.getLocalizedMessage(), GB.hexdump(protobufFragment.fragmentBytes)); + return null; + } + boolean processed = false; + if (smart.hasCoreService()) { //TODO: unify request and response??? + processed = true; + processProtobufCoreResponse(smart.getCoreService()); +// return prepareProtobufResponse(processProtobufCoreRequest(smart.getCoreService()), message.getRequestId()); + } + if (smart.hasCalendarService()) { + return prepareProtobufResponse(processProtobufCalendarRequest(smart.getCalendarService()), message.getRequestId()); + } + if (smart.hasDeviceStatusService()) { + processed = true; + processProtobufDeviceStatusResponse(smart.getDeviceStatusService()); + } + if (smart.hasFindMyWatchService()) { + processed = true; + processProtobufFindMyWatchResponse(smart.getFindMyWatchService()); + } + if (!processed) { + LOG.warn("Unknown protobuf request: {}", smart); + } + } + return null; + } + + public ProtobufMessage processIncoming(ProtobufStatusMessage statusMessage) { + LOG.info("Processing protobuf status message #{}@{}: status={}, error={}", statusMessage.getRequestId(), statusMessage.getDataOffset(), statusMessage.getProtobufStatus(), statusMessage.getError()); + //TODO: check status and react accordingly, right now we blindly proceed to next chunk + if (chunkedFragmentsMap.containsKey(statusMessage.getRequestId()) && statusMessage.isOK()) { + final ProtobufFragment protobufFragment = chunkedFragmentsMap.get(statusMessage.getRequestId()); + LOG.debug("Protobuf message #{} found in queue: {}", statusMessage.getRequestId(), GB.hexdump(protobufFragment.fragmentBytes)); + + if (protobufFragment.totalLength <= (statusMessage.getDataOffset() + maxChunkSize)) { + chunkedFragmentsMap.remove(protobufFragment); + } + + return protobufFragment.getNextChunk(statusMessage); + } + return null; + } + + private ProtobufFragment processChunkedMessage(ProtobufMessage message) { + if (message.isComplete()) //comment this out if for any reason also smaller messages should end up in the map + return new ProtobufFragment(message.getMessageBytes()); + + if (message.getDataOffset() == 0) { //store new messages beginning at 0, overwrite old messages + chunkedFragmentsMap.put(message.getRequestId(), new ProtobufFragment(message)); + LOG.info("Protobuf request put in queue: #{} , {}", message.getRequestId(), GB.hexdump(message.getMessageBytes())); + } else { + if (chunkedFragmentsMap.containsKey(message.getRequestId())) { + ProtobufFragment oldFragment = chunkedFragmentsMap.get(message.getRequestId()); + chunkedFragmentsMap.put(message.getRequestId(), + new ProtobufFragment(oldFragment, message)); + } + } + return chunkedFragmentsMap.get(message.getRequestId()); + } + + private GdiSmartProto.Smart processProtobufCalendarRequest(GdiCalendarService.CalendarService calendarService) { + if (calendarService.hasCalendarRequest()) { + GdiCalendarService.CalendarService.CalendarServiceRequest calendarServiceRequest = calendarService.getCalendarRequest(); + + CalendarManager upcomingEvents = new CalendarManager(deviceSupport.getContext(), deviceSupport.getDevice().getAddress()); + List mEvents = upcomingEvents.getCalendarEventList(); + List watchEvents = new ArrayList<>(); + + for (CalendarEvent mEvt : mEvents) { + if (mEvt.getEndSeconds() < calendarServiceRequest.getBegin() || + mEvt.getBeginSeconds() > calendarServiceRequest.getEnd()) { + LOG.debug("CalendarService Skipping event {} that is out of requested time range", mEvt.getTitle()); + continue; + } + + watchEvents.add(GdiCalendarService.CalendarService.CalendarEvent.newBuilder() + .setTitle(mEvt.getTitle()) + .setAllDay(mEvt.isAllDay()) + .setBegin(mEvt.getBeginSeconds()) + .setEnd(mEvt.getEndSeconds()) + .setLocation(StringUtils.defaultString(mEvt.getLocation())) + .setDescription(StringUtils.defaultString(mEvt.getDescription())) + .build() + ); + } + + LOG.debug("CalendarService Sending {} events to watch", watchEvents.size()); + return GdiSmartProto.Smart.newBuilder().setCalendarService( + GdiCalendarService.CalendarService.newBuilder().setCalendarResponse( + GdiCalendarService.CalendarService.CalendarServiceResponse.newBuilder() + .addAllCalendarEvent(watchEvents) + .setUnknown(1) + ) + ).build(); + } + LOG.warn("Unknown CalendarService request: {}", calendarService); + return GdiSmartProto.Smart.newBuilder().setCalendarService( + GdiCalendarService.CalendarService.newBuilder().setCalendarResponse( + GdiCalendarService.CalendarService.CalendarServiceResponse.newBuilder() + .setUnknown(0) + ) + ).build(); + } + + private void processProtobufCoreResponse(GdiCore.CoreService coreService) { + if (coreService.hasSyncResponse()) { + final GdiCore.CoreService.SyncResponse syncResponse = coreService.getSyncResponse(); + LOG.info("Received sync status: {}", syncResponse.getStatus()); + } + LOG.warn("Unknown CoreService response: {}", coreService); + } + + private void processProtobufDeviceStatusResponse(GdiDeviceStatus.DeviceStatusService deviceStatusService) { + if (deviceStatusService.hasRemoteDeviceBatteryStatusResponse()) { + final GdiDeviceStatus.DeviceStatusService.RemoteDeviceBatteryStatusResponse batteryStatusResponse = deviceStatusService.getRemoteDeviceBatteryStatusResponse(); + final int batteryLevel = batteryStatusResponse.getCurrentBatteryLevel(); + LOG.info("Received remote battery status {}: level={}", batteryStatusResponse.getStatus(), batteryLevel); + final GBDeviceEventBatteryInfo batteryEvent = new GBDeviceEventBatteryInfo(); + batteryEvent.level = (short) batteryLevel; + deviceSupport.evaluateGBDeviceEvent(batteryEvent); + return; + } + if (deviceStatusService.hasActivityStatusResponse()) { + final GdiDeviceStatus.DeviceStatusService.ActivityStatusResponse activityStatusResponse = deviceStatusService.getActivityStatusResponse(); + LOG.info("Received activity status: {}", activityStatusResponse.getStatus()); + return; + } + LOG.warn("Unknown DeviceStatusService response: {}", deviceStatusService); + } + +// private GdiSmartProto.Smart processProtobufCoreRequest(GdiCore.CoreService coreService) { +// if (coreService.hasLocationUpdatedSetEnabledRequest()) { //TODO: enable location support in devicesupport +// LOG.debug("Location CoreService: {}", coreService); +// +// final GdiCore.CoreService.LocationUpdatedSetEnabledRequest locationUpdatedSetEnabledRequest = coreService.getLocationUpdatedSetEnabledRequest(); +// +// LOG.info("Received locationUpdatedSetEnabledRequest status: {}", locationUpdatedSetEnabledRequest.getEnabled()); +// +// GdiCore.CoreService.LocationUpdatedSetEnabledResponse.Builder response = GdiCore.CoreService.LocationUpdatedSetEnabledResponse.newBuilder() +// .setStatus(GdiCore.CoreService.LocationUpdatedSetEnabledResponse.Status.OK); +// +// //TODO: check and follow the preference in coordinator (see R.xml.devicesettings_workout_send_gps_to_band ) +// if(locationUpdatedSetEnabledRequest.getEnabled()) { +// response.addRequests(GdiCore.CoreService.LocationUpdatedSetEnabledResponse.Requested.newBuilder() +// .setRequested(locationUpdatedSetEnabledRequest.getRequests(0).getRequested()) +// .setStatus(GdiCore.CoreService.LocationUpdatedSetEnabledResponse.Requested.RequestedStatus.OK)); +// } +// +// deviceSupport.processLocationUpdateRequest(locationUpdatedSetEnabledRequest.getEnabled(), locationUpdatedSetEnabledRequest.getRequestsList()); +// +// return GdiSmartProto.Smart.newBuilder().setCoreService( +// GdiCore.CoreService.newBuilder().setLocationUpdatedSetEnabledResponse(response)).build(); +// } +// LOG.warn("Unknown CoreService request: {}", coreService); +// return null; +// } + + private void processProtobufFindMyWatchResponse(GdiFindMyWatch.FindMyWatchService findMyWatchService) { + if (findMyWatchService.hasCancelRequest()) { + LOG.info("Watch found"); + } + if (findMyWatchService.hasCancelResponse() || findMyWatchService.hasFindResponse()) { + LOG.debug("Received findMyWatch response"); + } + LOG.warn("Unknown FindMyWatchService response: {}", findMyWatchService); + } + + public ProtobufMessage prepareProtobufRequest(GdiSmartProto.Smart protobufPayload) { + if (null == protobufPayload) + return null; + final int requestId = getNextProtobufRequestId(); + return prepareProtobufMessage(protobufPayload.toByteArray(), GFDIMessage.GarminMessage.PROTOBUF_REQUEST.getId(), requestId); + } + + private ProtobufMessage prepareProtobufResponse(GdiSmartProto.Smart protobufPayload, int requestId) { + if (null == protobufPayload) + return null; + return prepareProtobufMessage(protobufPayload.toByteArray(), GFDIMessage.GarminMessage.PROTOBUF_RESPONSE.getId(), requestId); + } + + private ProtobufMessage prepareProtobufMessage(byte[] bytes, int messageType, int requestId) { + if (bytes == null || bytes.length == 0) + return null; + LOG.info("Preparing protobuf message. Type{}, #{}, {}B: {}", messageType, requestId, bytes.length, GB.hexdump(bytes, 0, bytes.length)); + + if (bytes.length > maxChunkSize) { + chunkedFragmentsMap.put(requestId, new ProtobufFragment(bytes)); + return new ProtobufMessage(messageType, + requestId, + 0, + bytes.length, + maxChunkSize, + ArrayUtils.subarray(bytes, 0, maxChunkSize)); + } + return new ProtobufMessage(messageType, requestId, 0, bytes.length, bytes.length, bytes); + } + + class ProtobufFragment { + private final byte[] fragmentBytes; + private final int totalLength; + + public ProtobufFragment(byte[] fragmentBytes) { + this.fragmentBytes = fragmentBytes; + this.totalLength = fragmentBytes.length; + } + + public ProtobufFragment(ProtobufMessage message) { + if (message.getDataOffset() != 0) + throw new IllegalArgumentException("Cannot create fragment if message is not the first of the sequence"); + this.fragmentBytes = message.getMessageBytes(); + this.totalLength = message.getTotalProtobufLength(); + } + + public ProtobufFragment(ProtobufFragment existing, ProtobufMessage toMerge) { + if (toMerge.getDataOffset() != existing.fragmentBytes.length) + throw new IllegalArgumentException("Cannot merge fragment: incoming message has different offset than needed"); + this.fragmentBytes = ArrayUtils.addAll(existing.fragmentBytes, toMerge.getMessageBytes()); + this.totalLength = existing.totalLength; + } + + public ProtobufMessage getNextChunk(ProtobufStatusMessage protobufStatusMessage) { + int start = protobufStatusMessage.getDataOffset() + maxChunkSize; + int length = Math.min(maxChunkSize, this.fragmentBytes.length - start); + + return new ProtobufMessage(protobufStatusMessage.getMessageType(), + protobufStatusMessage.getRequestId(), + start, + this.totalLength, + length, + ArrayUtils.subarray(this.fragmentBytes, start, start + length)); + } + + public boolean isComplete() { + return totalLength == fragmentBytes.length; + } + } + + +} 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 new file mode 100644 index 000000000..96da30cad --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/CobsCoDec.java @@ -0,0 +1,134 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator; + +import java.nio.ByteBuffer; + +public class CobsCoDec { + private static final long BUFFER_TIMEOUT = 1500L; // turn this value up while debugging + private final ByteBuffer byteBuffer = ByteBuffer.allocate(1000); + private long lastUpdate; + private byte[] cobsDecodedMessage; + + /** + * Accumulates received bytes in a local buffer, clearing it after a timeout, and attempts to + * parse it. + * + * @param bytes + */ + public void receivedBytes(byte[] bytes) { + final long now = System.currentTimeMillis(); + if ((now - lastUpdate) > BUFFER_TIMEOUT) { + reset(); + } + lastUpdate = now; + + byteBuffer.put(bytes); + decode(); + } + + private void reset() { + cobsDecodedMessage = null; + byteBuffer.clear(); + } + + public byte[] retrieveMessage() { + final byte[] resultPacket = cobsDecodedMessage; + cobsDecodedMessage = null; + return resultPacket; + } + + + /** + * COBS decoding algorithm variant, which relies on a leading and a trailing 0 byte (the former + * is not part of default implementations). + * This function removes the complete message from the internal buffer, if it could be decoded. + */ + private void decode() { + if (cobsDecodedMessage != null) { + // packet is waiting, unable to parse more + return; + } + if (byteBuffer.position() < 4) { + // minimal payload length including the padding + return; + } + if (0 != byteBuffer.get(byteBuffer.position() - 1)) + return; //no 0x00 at the end, hence no full packet + byteBuffer.position(byteBuffer.position() - 1); //don't process the trailing 0 + byteBuffer.flip(); + if (0 != byteBuffer.get()) + return; //no 0x00 at the start + ByteBuffer decodedBytesBuffer = ByteBuffer.allocate(byteBuffer.limit()); //leading and trailing 0x00 bytes + while (byteBuffer.hasRemaining()) { + byte code = byteBuffer.get(); + if (code == 0) { + break; + } + int codeValue = code & 0xFF; + int payloadSize = codeValue - 1; + for (int i = 0; i < payloadSize; i++) { + decodedBytesBuffer.put(byteBuffer.get()); + } + if (codeValue != 0xFF && byteBuffer.hasRemaining()) { + decodedBytesBuffer.put((byte) 0); // Append a zero byte after the payload + } + } + + decodedBytesBuffer.flip(); + cobsDecodedMessage = new byte[decodedBytesBuffer.remaining()]; + decodedBytesBuffer.get(cobsDecodedMessage); + byteBuffer.compact(); + } + + // 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) { + ByteBuffer encodedBytesBuffer = ByteBuffer.allocate((data.length * 2) + 1); // Maximum expansion + + encodedBytesBuffer.put((byte) 0);// Garmin initial padding + ByteBuffer buffer = ByteBuffer.wrap(data); + + while (buffer.position() < buffer.limit()) { + int startPos = buffer.position(); + int zeroIndex = buffer.position(); + + while (buffer.hasRemaining() && buffer.get() != 0) { + zeroIndex++; + } + + int payloadSize = zeroIndex - startPos; + + while (payloadSize > 0xFE) { + encodedBytesBuffer.put((byte) 0xFF); // Maximum payload size indicator + for (int i = 0; i < 0xFE; i++) { + encodedBytesBuffer.put(data[startPos + i]); + } + payloadSize -= 0xFE; + startPos += 0xFE; + } + + encodedBytesBuffer.put((byte) (payloadSize + 1)); + + for (int i = startPos; i < zeroIndex; i++) { + encodedBytesBuffer.put(data[i]); + } + + if (buffer.hasRemaining()) { + zeroIndex++; // Include the zero byte in the next block + } + + if (!buffer.hasRemaining() && payloadSize == 0) { + break; + } + + buffer.position(zeroIndex); + } + + encodedBytesBuffer.put((byte) 0); // Append a zero byte to indicate end of encoding + encodedBytesBuffer.flip(); + + byte[] encodedBytes = new byte[encodedBytesBuffer.remaining()]; + encodedBytesBuffer.get(encodedBytes); + + return encodedBytes; + } + +} 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 new file mode 100644 index 000000000..dd6e5f336 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/ICommunicator.java @@ -0,0 +1,18 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator; + +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCharacteristic; + +import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; + +public interface ICommunicator { + void sendMessage(byte[] message); + + void initializeDevice(TransactionBuilder builder); + + boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic); + + interface Callback { + void onMessage(byte[] message); + } +} 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 new file mode 100644 index 000000000..f946de6fd --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/v1/CommunicatorV1.java @@ -0,0 +1,36 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.v1; + +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCharacteristic; + +import java.util.UUID; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.ICommunicator; + +public class CommunicatorV1 implements ICommunicator { + public static final UUID UUID_SERVICE_GARMIN_GFDI = VivomoveConstants.UUID_SERVICE_GARMIN_GFDI; + + private final GarminSupport mSupport; + + public CommunicatorV1(final GarminSupport garminSupport) { + this.mSupport = garminSupport; + } + + @Override + public void initializeDevice(final TransactionBuilder builder) { + + } + + @Override + public void sendMessage(final byte[] message) { + + } + + @Override + public boolean onCharacteristicChanged(final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic) { + return false; + } +} 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 new file mode 100644 index 000000000..44a23f086 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/communicator/v2/CommunicatorV2.java @@ -0,0 +1,173 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.v2; + +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCharacteristic; + +import org.apache.commons.lang3.ArrayUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Arrays; +import java.util.UUID; + +import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.CobsCoDec; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.ICommunicator; +import nodomain.freeyourgadget.gadgetbridge.util.GB; + +public class CommunicatorV2 implements ICommunicator { + public static final UUID UUID_SERVICE_GARMIN_ML_GFDI = UUID.fromString("6A4E2800-667B-11E3-949A-0800200C9A66"); //VivomoveConstants.UUID_SERVICE_GARMIN_ML_GFDI; + public static final UUID UUID_CHARACTERISTIC_GARMIN_ML_GFDI_SEND = UUID.fromString("6a4e2822-667b-11e3-949a-0800200c9a66"); //VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_ML_GFDI_SEND; + public static final UUID UUID_CHARACTERISTIC_GARMIN_ML_GFDI_RECEIVE = UUID.fromString("6a4e2812-667b-11e3-949a-0800200c9a66"); //VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_ML_GFDI_RECEIVE; + + public static final int MAX_WRITE_SIZE = 20; //VivomoveConstants.MAX_WRITE_SIZE + private static final Logger LOG = LoggerFactory.getLogger(CommunicatorV2.class); + public final CobsCoDec cobsCoDec; + private final GarminSupport mSupport; + private final long gadgetBridgeClientID = 2L; + private int gfdiHandle = 0; + + public CommunicatorV2(final GarminSupport garminSupport) { + this.mSupport = garminSupport; + this.cobsCoDec = new CobsCoDec(); + } + + @Override + public void initializeDevice(final TransactionBuilder builder) { + builder.notify(this.mSupport.getCharacteristic(UUID_CHARACTERISTIC_GARMIN_ML_GFDI_RECEIVE), true); + builder.write(this.mSupport.getCharacteristic(UUID_CHARACTERISTIC_GARMIN_ML_GFDI_SEND), closeAllServices()); + } + + @Override + public void sendMessage(final byte[] message) { + if (null == message) + return; + if (0 == gfdiHandle) { + 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 TransactionBuilder builder = new TransactionBuilder("sendMessage()"); + int remainingBytes = payload.length; + if (remainingBytes > MAX_WRITE_SIZE - 1) { + int position = 0; + while (remainingBytes > 0) { + final byte[] fragment = Arrays.copyOfRange(payload, position, position + Math.min(remainingBytes, MAX_WRITE_SIZE - 1)); + builder.write(this.mSupport.getCharacteristic(UUID_CHARACTERISTIC_GARMIN_ML_GFDI_SEND), ArrayUtils.addAll(new byte[]{(byte) gfdiHandle}, fragment)); + position += fragment.length; + remainingBytes -= fragment.length; + } + } else { + builder.write(this.mSupport.getCharacteristic(UUID_CHARACTERISTIC_GARMIN_ML_GFDI_SEND), ArrayUtils.addAll(new byte[]{(byte) gfdiHandle}, payload)); + } + builder.queue(this.mSupport.getQueue()); + } + + @Override + public boolean onCharacteristicChanged(final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic) { + ByteBuffer message = ByteBuffer.wrap(characteristic.getValue()).order(ByteOrder.LITTLE_ENDIAN); +// LOG.debug("RECEIVED: {}", GB.hexdump(message.array())); + final byte handle = message.get(); + if (0x00 == handle) { //handle management message + + final byte type = message.get(); + final long incomingClientID = message.getLong(); + + if (incomingClientID != this.gadgetBridgeClientID) { + LOG.debug("Ignoring incoming message, client ID is not ours. Message: {}", GB.hexdump(message.array())); + } + RequestType requestType = RequestType.fromCode(type); + if (null == requestType) { + LOG.error("Unknown request type. Message: {}", message.array()); + return true; + } + switch (requestType) { + case REGISTER_ML_REQ: //register service request + case CLOSE_HANDLE_REQ: //close handle request + case CLOSE_ALL_REQ: //close all handles request + case UNK_REQ: //unknown request + LOG.warn("Received handle request, expecting responses. Message: {}", message.array()); + case REGISTER_ML_RESP: //register service response + LOG.debug("Received register response. Message: {}", message.array()); + final short registeredService = message.getShort(); + final byte status = message.get(); + if (0 == status && 1 == registeredService) { //success + this.gfdiHandle = message.get(); + } + break; + case CLOSE_HANDLE_RESP: //close handle response + LOG.debug("Received close handle response. Message: {}", message.array()); + break; + case CLOSE_ALL_RESP: //close all handles response + LOG.debug("Received close all handles response. Message: {}", message.array()); + new TransactionBuilder("open GFDI") + .write(this.mSupport.getCharacteristic(UUID_CHARACTERISTIC_GARMIN_ML_GFDI_SEND), registerGFDI()) + .queue(this.mSupport.getQueue()); + break; + case UNK_RESP: //unknown response + LOG.debug("Received unknown. Message: {}", message.array()); + break; + } + + return true; + } else if (this.gfdiHandle == handle) { + + byte[] partial = new byte[message.remaining()]; + message.get(partial); + this.cobsCoDec.receivedBytes(partial); + + this.mSupport.onMessage(this.cobsCoDec.retrieveMessage()); + + return true; + } + return false; + } + + protected byte[] closeAllServices() { + ByteBuffer toSend = ByteBuffer.allocate(13); + toSend.order(ByteOrder.BIG_ENDIAN); + toSend.putShort((short) RequestType.CLOSE_ALL_REQ.ordinal()); //close all services + toSend.order(ByteOrder.LITTLE_ENDIAN); + toSend.putLong(this.gadgetBridgeClientID); + toSend.putShort((short) 0); + return toSend.array(); + } + + protected byte[] registerGFDI() { + ByteBuffer toSend = ByteBuffer.allocate(13); + toSend.order(ByteOrder.BIG_ENDIAN); + toSend.putShort((short) RequestType.REGISTER_ML_REQ.ordinal()); //register service request + toSend.order(ByteOrder.LITTLE_ENDIAN); + toSend.putLong(this.gadgetBridgeClientID); + toSend.putShort((short) 1); //service GFDI + return toSend.array(); + } + + enum RequestType { + REGISTER_ML_REQ, + REGISTER_ML_RESP, + CLOSE_HANDLE_REQ, + CLOSE_HANDLE_RESP, + UNK_HANDLE, + CLOSE_ALL_REQ, + CLOSE_ALL_RESP, + UNK_REQ, + UNK_RESP; + + public static RequestType fromCode(final int code) { + for (final RequestType requestType : RequestType.values()) { + if (requestType.ordinal() == code) { + return requestType; + } + } + + return null; + } + } + + +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/ChecksumCalculator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/ChecksumCalculator.java new file mode 100644 index 000000000..a46fa76e8 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/ChecksumCalculator.java @@ -0,0 +1,47 @@ +/* Copyright (C) 2023-2024 Petr Kadlec + + 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.garmin.messages; + +import java.nio.ByteBuffer; + +public final class ChecksumCalculator { + private static final int[] CONSTANTS = {0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401, 0xA001, 0x6C00, 0x7800, 0xB401, 0x5000, 0x9C01, 0x8801, 0x4400}; + + private ChecksumCalculator() { + } + + public static int computeCrc(byte[] data, int offset, int length) { + return computeCrc(0, data, offset, length); + } + + public static int computeCrc(ByteBuffer byteBuffer, int offset, int length) { + byteBuffer.rewind(); + byte[] data = new byte[length]; + byteBuffer.get(data); + return computeCrc(0, data, offset, length); + } + + public static int computeCrc(int initialCrc, byte[] data, int offset, int length) { + int crc = initialCrc; + for (int i = offset; i < offset + length; ++i) { + int b = data[i]; + crc = (((crc >> 4) & 4095) ^ CONSTANTS[crc & 15]) ^ CONSTANTS[b & 15]; + crc = (((crc >> 4) & 4095) ^ CONSTANTS[crc & 15]) ^ CONSTANTS[(b >> 4) & 15]; + } + return crc; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/ConfigurationMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/ConfigurationMessage.java new file mode 100644 index 000000000..572c1abb3 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/ConfigurationMessage.java @@ -0,0 +1,43 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages; + +import java.util.Set; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.GarminCapability; + + +public class ConfigurationMessage extends GFDIMessage { + public final Set OUR_CAPABILITIES = GarminCapability.ALL_CAPABILITIES; + private final byte[] incomingConfigurationPayload; + private final int messageType; + private final byte[] ourConfigurationPayload = GarminCapability.setToBinary(OUR_CAPABILITIES); + + public ConfigurationMessage(int messageType, byte[] configurationPayload) { + this.messageType = messageType; + if (configurationPayload.length > 255) + throw new IllegalArgumentException("Too long payload"); + this.incomingConfigurationPayload = configurationPayload; + + Set capabilities = GarminCapability.setFromBinary(configurationPayload); + LOG.info("Received configuration message; capabilities: {}", GarminCapability.setToString(capabilities)); + + this.statusMessage = this.getStatusMessage(messageType); + } + + public static ConfigurationMessage parseIncoming(MessageReader reader, int messageType) { + final int endOfPayload = reader.readByte(); + ConfigurationMessage configurationMessage = new ConfigurationMessage(messageType, reader.readBytes(endOfPayload - reader.getPosition())); + reader.warnIfLeftover(); + return configurationMessage; + } + + @Override + protected boolean generateOutgoing() { + final MessageWriter writer = new MessageWriter(response); + writer.writeShort(0); // placeholder for packet size + writer.writeShort(messageType); + writer.writeByte(ourConfigurationPayload.length); + writer.writeBytes(ourConfigurationPayload); + return true; + } + +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/CurrentTimeRequestMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/CurrentTimeRequestMessage.java new file mode 100644 index 000000000..9588eadaa --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/CurrentTimeRequestMessage.java @@ -0,0 +1,52 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages; + + +import java.util.Calendar; +import java.util.TimeZone; + +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.GarminTimeUtils; + +public class CurrentTimeRequestMessage extends GFDIMessage { + private final int referenceID; + private final int messageType; + + public CurrentTimeRequestMessage(int messageType, int referenceID) { + this.messageType = messageType; + this.referenceID = referenceID; + this.statusMessage = this.getStatusMessage(messageType); + } + + public static CurrentTimeRequestMessage parseIncoming(MessageReader reader, int messageType) { + final int referenceID = reader.readInt(); + + reader.warnIfLeftover(); + return new CurrentTimeRequestMessage(messageType, referenceID); + } + + @Override + protected boolean generateOutgoing() { + long now = System.currentTimeMillis(); + final TimeZone timeZone = TimeZone.getDefault(); + final Calendar calendar = Calendar.getInstance(timeZone); + calendar.setTimeInMillis(now); + int dstOffset = calendar.get(Calendar.DST_OFFSET) / 1000; + int timeZoneOffset = timeZone.getOffset(now) / 1000; + int garminTimestamp = GarminTimeUtils.javaMillisToGarminTimestamp(now); + + LOG.info("Processing current time request #{}: time={}, DST={}, ofs={}", referenceID, garminTimestamp, dstOffset, timeZoneOffset); + + final MessageWriter writer = new MessageWriter(response); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(GarminMessage.RESPONSE.getId()); + writer.writeShort(messageType); + writer.writeByte(Status.ACK.ordinal()); + writer.writeInt(referenceID); + writer.writeInt(garminTimestamp); + writer.writeInt(timeZoneOffset); + // TODO: next DST start/end + writer.writeInt(0); + writer.writeInt(0); + + return true; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/DeviceInformationMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/DeviceInformationMessage.java new file mode 100644 index 000000000..282fadbf6 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/DeviceInformationMessage.java @@ -0,0 +1,91 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages; + +import android.bluetooth.BluetoothAdapter; +import android.os.Build; + +import java.util.Locale; + +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo; + +public class DeviceInformationMessage extends GFDIMessage { + + final int ourUnitNumber = -1; + final int ourSoftwareVersion = 7791; + final int ourMaxPacketSize = -1; + private final int messageType; + private final int incomingProtocolVersion; + private final int ourProtocolVersion = 150; + private final int incomingProductNumber; + private final int ourProductNumber = -1; + private final String incomingUnitNumber; + private final int incomingSoftwareVersion; + private final int incomingMaxPacketSize; + private final String bluetoothFriendlyName; + private final String deviceName; + private final String deviceModel; + // dual-pairing flags & MAC addresses... + + public DeviceInformationMessage(int messageType, int protocolVersion, int productNumber, String unitNumber, int softwareVersion, int maxPacketSize, String bluetoothFriendlyName, String deviceName, String deviceModel) { + this.messageType = messageType; + this.incomingProtocolVersion = protocolVersion; + this.incomingProductNumber = productNumber; + this.incomingUnitNumber = unitNumber; + this.incomingSoftwareVersion = softwareVersion; + this.incomingMaxPacketSize = maxPacketSize; + this.bluetoothFriendlyName = bluetoothFriendlyName; + this.deviceName = deviceName; + this.deviceModel = deviceModel; + } + + public static DeviceInformationMessage parseIncoming(MessageReader reader, int messageType) { + final int protocolVersion = reader.readShort(); + final int productNumber = reader.readShort(); + final String unitNumber = Long.toString(reader.readInt() & 0xFFFFFFFFL); + final int softwareVersion = reader.readShort(); + final int maxPacketSize = reader.readShort(); + final String bluetoothFriendlyName = reader.readString(); + final String deviceName = reader.readString(); + final String deviceModel = reader.readString(); + + reader.warnIfLeftover(); + return new DeviceInformationMessage(messageType, protocolVersion, productNumber, unitNumber, softwareVersion, maxPacketSize, bluetoothFriendlyName, deviceName, deviceModel); + } + + @Override + protected boolean generateOutgoing() { + + final int protocolFlags = this.incomingProtocolVersion / 100 == 1 ? 1 : 0; + + final MessageWriter writer = new MessageWriter(response); + writer.writeShort(0); // placeholder for packet size + writer.writeShort(GarminMessage.RESPONSE.getId()); + writer.writeShort(messageType); + writer.writeByte(Status.ACK.ordinal()); + writer.writeShort(ourProtocolVersion); + writer.writeShort(ourProductNumber); + writer.writeInt(ourUnitNumber); + writer.writeShort(ourSoftwareVersion); + writer.writeShort(ourMaxPacketSize); + writer.writeString(BluetoothAdapter.getDefaultAdapter().getName()); + writer.writeString(Build.MANUFACTURER); + writer.writeString(Build.DEVICE); + writer.writeByte(protocolFlags); + return true; + } + + public GBDeviceEventVersionInfo getGBDeviceEvent() { + + LOG.info("Received device information: protocol {}, product {}, unit {}, SW {}, max packet {}, BT name {}, device name {}, device model {}", incomingProtocolVersion, incomingProductNumber, incomingUnitNumber, getSoftwareVersionStr(), incomingMaxPacketSize, bluetoothFriendlyName, deviceName, deviceModel); + + GBDeviceEventVersionInfo versionCmd = new GBDeviceEventVersionInfo(); + versionCmd.fwVersion = getSoftwareVersionStr(); + versionCmd.hwVersion = deviceModel; + return versionCmd; + } + + private String getSoftwareVersionStr() { + int softwareVersionMajor = incomingSoftwareVersion / 100; + int softwareVersionMinor = incomingSoftwareVersion % 100; + return String.format(Locale.ROOT, "%d.%02d", softwareVersionMajor, softwareVersionMinor); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/FindMyPhoneRequestMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/FindMyPhoneRequestMessage.java new file mode 100644 index 000000000..29133ec10 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/FindMyPhoneRequestMessage.java @@ -0,0 +1,34 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages; + + +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventFindPhone; + +public class FindMyPhoneRequestMessage extends GFDIMessage { + private final int duration; + private final int messageType; + + public FindMyPhoneRequestMessage(int messageType, int duration) { + this.messageType = messageType; + this.duration = duration; + } + + public static FindMyPhoneRequestMessage parseIncoming(MessageReader reader, int messageType) { + final int duration = reader.readByte(); + + reader.warnIfLeftover(); + return new FindMyPhoneRequestMessage(messageType, duration); + } + + @Override + public GBDeviceEvent getGBDeviceEvent() { + final GBDeviceEventFindPhone findPhoneEvent = new GBDeviceEventFindPhone(); + findPhoneEvent.event = messageType == GarminMessage.FIND_MY_PHONE.getId() ? GBDeviceEventFindPhone.Event.START : GBDeviceEventFindPhone.Event.STOP; + return findPhoneEvent; + } + + @Override + protected boolean generateOutgoing() { + return false; + } +} 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 new file mode 100644 index 000000000..5f32aa10f --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/GFDIMessage.java @@ -0,0 +1,159 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages; + +import androidx.annotation.Nullable; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.reflect.Method; +import java.nio.ByteBuffer; + +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent; + +public abstract class GFDIMessage { + public static final int MESSAGE_RESPONSE = 5000; //TODO: MESSAGE_STATUS is a better name? + public static final int MESSAGE_REQUEST = 5001; + public static final int MESSAGE_DOWNLOAD_REQUEST = 5002; + public static final int MESSAGE_UPLOAD_REQUEST = 5003; + public static final int MESSAGE_FILE_TRANSFER_DATA = 5004; + public static final int MESSAGE_CREATE_FILE_REQUEST = 5005; + public static final int MESSAGE_DIRECTORY_FILE_FILTER_REQUEST = 5007; + public static final int MESSAGE_FILE_READY = 5009; + public static final int MESSAGE_FIT_DEFINITION = 5011; + public static final int MESSAGE_FIT_DATA = 5012; + public static final int MESSAGE_WEATHER_REQUEST = 5014; + public static final int MESSAGE_BATTERY_STATUS = 5023; + public static final int MESSAGE_DEVICE_INFORMATION = 5024; + public static final int MESSAGE_DEVICE_SETTINGS = 5026; + public static final int MESSAGE_SYSTEM_EVENT = 5030; + public static final int MESSAGE_SUPPORTED_FILE_TYPES_REQUEST = 5031; + public static final int MESSAGE_NOTIFICATION_SOURCE = 5033; + public static final int MESSAGE_GNCS_CONTROL_POINT_REQUEST = 5034; + public static final int MESSAGE_GNCS_DATA_SOURCE = 5035; + public static final int MESSAGE_NOTIFICATION_SERVICE_SUBSCRIPTION = 5036; + public static final int MESSAGE_SYNC_REQUEST = 5037; + public static final int MESSAGE_FIND_MY_PHONE = 5039; + public static final int MESSAGE_CANCEL_FIND_MY_PHONE = 5040; + public static final int MESSAGE_MUSIC_CONTROL = 5041; + public static final int MESSAGE_MUSIC_CONTROL_CAPABILITIES = 5042; + public static final int MESSAGE_PROTOBUF_REQUEST = 5043; + public static final int MESSAGE_PROTOBUF_RESPONSE = 5044; + public static final int MESSAGE_MUSIC_CONTROL_ENTITY_UPDATE = 5049; + public static final int MESSAGE_CONFIGURATION = 5050; + public static final int MESSAGE_CURRENT_TIME_REQUEST = 5052; + public static final int MESSAGE_AUTH_NEGOTIATION = 5101; + protected static final Logger LOG = LoggerFactory.getLogger(GFDIMessage.class); + protected final ByteBuffer response = ByteBuffer.allocate(1000); + protected GFDIStatusMessage statusMessage; + + public static GFDIMessage parseIncoming(byte[] message) { + final MessageReader messageReader = new MessageReader(message); + + final int messageType = messageReader.readShort(); + try { +// Class objectClass = GarminMessage.fromId(messageType); + Method m = GarminMessage.fromId(messageType).getMethod("parseIncoming", MessageReader.class, int.class); + return GarminMessage.fromId(messageType).cast(m.invoke(null, messageReader, messageType)); + } catch (Exception e) { + LOG.error("UNHANDLED GFDI MESSAGE TYPE {}, MESSAGE {}", messageType, message); + return new UnhandledMessage(messageType); + } + } + + protected abstract boolean generateOutgoing(); + + public byte[] getOutgoingMessage() { + response.clear(); + boolean toSend = generateOutgoing(); + if (!toSend) + return null; + addLengthAndChecksum(); + response.flip(); + + final byte[] packet = new byte[response.limit()]; + response.get(packet); + return packet; + } + + protected GFDIStatusMessage getStatusMessage(int messageType) { + return new GenericStatusMessage(messageType, Status.ACK); + } + + public GBDeviceEvent getGBDeviceEvent() { + return null; + } + + public byte[] getAckBytestream() { + if (null == this.statusMessage) { + return null; + } + return this.statusMessage.getOutgoingMessage(); + } + + private void addLengthAndChecksum() { + response.putShort(0, (short) (response.position() + 2)); + response.putShort((short) ChecksumCalculator.computeCrc(response.asReadOnlyBuffer(), 0, response.position())); + } + + public enum GarminMessage { + RESPONSE(5000, GFDIStatusMessage.class), //TODO: STATUS is a better name? + SYSTEM_EVENT(5030, SystemEventMessage.class), + DEVICE_INFORMATION(5024, DeviceInformationMessage.class), + FIND_MY_PHONE(5039, FindMyPhoneRequestMessage.class), + CANCEL_FIND_MY_PHONE(5040, FindMyPhoneRequestMessage.class), + MUSIC_CONTROL(5041, MusicControlMessage.class), + MUSIC_CONTROL_CAPABILITIES(5042, MusicControlCapabilitiesMessage.class), + PROTOBUF_REQUEST(5043, ProtobufMessage.class), + PROTOBUF_RESPONSE(5044, ProtobufMessage.class), + MUSIC_CONTROL_ENTITY_UPDATE(5049, MusicControlEntityUpdateMessage.class), + CONFIGURATION(5050, ConfigurationMessage.class), + CURRENT_TIME_REQUEST(5052, CurrentTimeRequestMessage.class), + ; + private final Class objectClass; + private final int id; + + GarminMessage(int id, Class objectClass) { + this.id = id; + this.objectClass = objectClass; + } + + public static Class fromId(final int id) { + for (final GarminMessage garminMessage : GarminMessage.values()) { + if (garminMessage.getId() == id) { + return garminMessage.getObjectClass(); + } + } + return null; + } + + public int getId() { + return id; + } + + private Class getObjectClass() { + return objectClass; + } + } + + public enum Status { + ACK, + NAK, + UNSUPPORTED, + DECODE_ERROR, + CRC_ERROR, + LENGTH_ERROR; + + @Nullable + public static Status fromCode(final int code) { + for (final Status status : Status.values()) { + if (status.ordinal() == code) { + return status; + } + } + + return null; + } + + } + +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/GFDIStatusMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/GFDIStatusMessage.java new file mode 100644 index 000000000..2b43e3ac1 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/GFDIStatusMessage.java @@ -0,0 +1,28 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages; + + +public abstract class GFDIStatusMessage extends GFDIMessage { + Status status; + + public static GFDIStatusMessage parseIncoming(MessageReader reader, int messageType) { + final int requestMessageType = reader.readShort(); + if (GarminMessage.PROTOBUF_REQUEST.getId() == requestMessageType || GarminMessage.PROTOBUF_RESPONSE.getId() == requestMessageType) { + return ProtobufStatusMessage.parseIncoming(reader, messageType); + } else { + final Status status = Status.fromCode(reader.readByte()); + + reader.warnIfLeftover(); + return new GenericStatusMessage(messageType, status); + } + } + + @Override + protected boolean generateOutgoing() { + return false; + } + + protected Status getStatus() { + return status; + } + +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/GenericStatusMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/GenericStatusMessage.java new file mode 100644 index 000000000..3068036d3 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/GenericStatusMessage.java @@ -0,0 +1,23 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages; + +public class GenericStatusMessage extends GFDIStatusMessage { + + private final int messageType; + private final Status status; + + public GenericStatusMessage(int originalRequestID, Status status) { + this.messageType = originalRequestID; + this.status = status; + } + + @Override + protected boolean generateOutgoing() { + final MessageWriter writer = new MessageWriter(response); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(GarminMessage.RESPONSE.getId()); + writer.writeShort(messageType); + writer.writeByte(status.ordinal()); + return true; + } + +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/MessageReader.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/MessageReader.java new file mode 100644 index 000000000..d4595a5a5 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/MessageReader.java @@ -0,0 +1,128 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; + +import nodomain.freeyourgadget.gadgetbridge.util.GB; + +public class MessageReader { + protected static final Logger LOG = LoggerFactory.getLogger(MessageReader.class); + + private final ByteBuffer byteBuffer; + + private final int payloadSize; + + public MessageReader(byte[] data) { + this.byteBuffer = ByteBuffer.wrap(data); + this.byteBuffer.order(ByteOrder.LITTLE_ENDIAN); + this.payloadSize = readShort(); + + checkSize(); + checkCRC(); + } + + public void setByteOrder(ByteOrder byteOrder) { + this.byteBuffer.order(byteOrder); + } + + public boolean isEof() { + return !byteBuffer.hasRemaining(); + } + + public int getPosition() { + return byteBuffer.position(); + } + + public void skip(int offset) { + if (byteBuffer.remaining() < offset) throw new IllegalStateException(); + byteBuffer.position(byteBuffer.position() + offset); + } + + public int readByte() { + if (!byteBuffer.hasRemaining()) throw new IllegalStateException(); + + return Byte.toUnsignedInt(byteBuffer.get()); + } + + public int readShort() { + if (byteBuffer.remaining() < 2) throw new IllegalStateException(); + + return Short.toUnsignedInt(byteBuffer.getShort()); + } + + public int readInt() { + if (byteBuffer.remaining() < 4) throw new IllegalStateException(); + + return byteBuffer.getInt(); + } + + public long readLong() { + if (byteBuffer.remaining() < 8) throw new IllegalStateException(); + + return byteBuffer.getLong(); + } + + public float readFloat32() { + if (byteBuffer.remaining() < 4) throw new IllegalStateException(); + + return byteBuffer.getFloat(); + } + + public double readFloat64() { + if (byteBuffer.remaining() < 8) throw new IllegalStateException(); + + return byteBuffer.getDouble(); + } + + public String readString() { + final int size = readByte(); + byte[] bytes = new byte[size]; + if (byteBuffer.remaining() < size) throw new IllegalStateException(); + byteBuffer.get(bytes); + return new String(bytes, StandardCharsets.UTF_8); + } + + public byte[] readBytes(int size) { + byte[] bytes = new byte[size]; + + if (byteBuffer.remaining() < size) throw new IllegalStateException(); + byteBuffer.get(bytes); + + return bytes; + } + + private int getCapacity() { + return byteBuffer.capacity(); + } + + + private void checkSize() { + if (payloadSize > getCapacity()) { + LOG.error("Received GFDI packet with invalid length: {} vs {}", payloadSize, getCapacity()); + throw new IllegalArgumentException("Received GFDI packet with invalid length"); + } + } + + private void checkCRC() { + final int crc = Short.toUnsignedInt(byteBuffer.getShort(payloadSize - 2)); + final int correctCrc = ChecksumCalculator.computeCrc(byteBuffer.asReadOnlyBuffer(), 0, payloadSize - 2); + if (crc != correctCrc) { + LOG.error("Received GFDI packet with invalid CRC: {} vs {}", crc, correctCrc); + throw new IllegalArgumentException("Received GFDI packet with invalid CRC"); + } + } + + + public void warnIfLeftover() { + if (byteBuffer.hasRemaining() && byteBuffer.position() < (byteBuffer.limit() - 2)) { + int numBytes = (byteBuffer.limit() - 2) - byteBuffer.position(); + byte[] leftover = new byte[numBytes]; + byteBuffer.get(leftover); + LOG.warn("Leftover bytes when parsing message. Bytes: {}, complete message: {}", GB.hexdump(leftover), GB.hexdump(byteBuffer.array())); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/MessageWriter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/MessageWriter.java new file mode 100644 index 000000000..2ece083a1 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/MessageWriter.java @@ -0,0 +1,87 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +public class MessageWriter { + private static final int DEFAULT_BUFFER_SIZE = 16384; + private final ByteBuffer byteBuffer; + + public MessageWriter() { + this(DEFAULT_BUFFER_SIZE); + } + + public MessageWriter(int bufferSize) { + this.byteBuffer = ByteBuffer.allocate(bufferSize); + this.byteBuffer.order(ByteOrder.LITTLE_ENDIAN); + } + + public MessageWriter(ByteBuffer byteBuffer) { + this.byteBuffer = byteBuffer; + this.byteBuffer.clear(); + this.byteBuffer.order(ByteOrder.LITTLE_ENDIAN); + } + + public void setByteOrder(ByteOrder byteOrder) { + this.byteBuffer.order(byteOrder); + } + + public void writeByte(int value) { + byteBuffer.put((byte) value); + } + + public void writeShort(int value) { + byteBuffer.putShort((short) value); + } + + public void writeInt(int value) { + byteBuffer.putInt(value); + } + + public void writeLong(long value) { + byteBuffer.putLong(value); + } + + public void writeFloat32(float value) { + byteBuffer.putFloat(value); + } + + public void writeFloat64(double value) { + byteBuffer.putDouble(value); + } + + public void writeString(String value) { + final byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + final int size = bytes.length; + if (size > 255) throw new IllegalArgumentException("Too long string"); + + if (byteBuffer.position() + 1 + size > byteBuffer.capacity()) + throw new IllegalStateException(); + byteBuffer.put((byte) size); + byteBuffer.put(bytes); + } + + public byte[] getBytes() { + //TODO: implement the correct flip()/compat() logic + return byteBuffer.hasRemaining() ? Arrays.copyOf(byteBuffer.array(), byteBuffer.position()) : byteBuffer.array(); + } + + public byte[] peekBytes() { + return byteBuffer.array(); + } + + public int getSize() { + return byteBuffer.position(); + } + + public void writeBytes(byte[] bytes) { + writeBytes(bytes, 0, bytes.length); + } + + public void writeBytes(byte[] bytes, int offset, int size) { + if (byteBuffer.position() + size > byteBuffer.capacity()) throw new IllegalStateException(); + byteBuffer.put(Arrays.copyOfRange(bytes, offset, offset + size)); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/MusicControlCapabilitiesMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/MusicControlCapabilitiesMessage.java new file mode 100644 index 000000000..ea61e7989 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/MusicControlCapabilitiesMessage.java @@ -0,0 +1,50 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages; + +public class MusicControlCapabilitiesMessage extends GFDIMessage { + + private final int supportedCapabilities; + private final GarminMusicControlCommand[] commands = GarminMusicControlCommand.values(); + private final int messageType; + + public MusicControlCapabilitiesMessage(int messageType, int supportedCapabilities) { + this.messageType = messageType; + this.supportedCapabilities = supportedCapabilities; + this.statusMessage = this.getStatusMessage(messageType); + } + + public static MusicControlCapabilitiesMessage parseIncoming(MessageReader reader, int messageType) { + final int supportedCapabilities = reader.readByte(); + + reader.warnIfLeftover(); + return new MusicControlCapabilitiesMessage(messageType, supportedCapabilities); + } + + @Override + protected boolean generateOutgoing() { + if (commands.length > 255) + throw new IllegalArgumentException("Too many supported commands"); + + final MessageWriter writer = new MessageWriter(response); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(GarminMessage.RESPONSE.getId()); + writer.writeShort(messageType); + writer.writeByte(Status.ACK.ordinal()); + writer.writeByte(commands.length); + for (GarminMusicControlCommand command : commands) { + writer.writeByte(command.ordinal()); + } + return true; + } + + enum GarminMusicControlCommand { + TOGGLE_PLAY_PAUSE, + SKIP_TO_NEXT_ITEM, + SKIP_TO_PREVIOUS_ITEM, + VOLUME_UP, + VOLUME_DOWN, + PLAY, + PAUSE, + SKIP_FORWARD, + SKIP_BACKWARDS + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/MusicControlEntityUpdateMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/MusicControlEntityUpdateMessage.java new file mode 100644 index 000000000..58d1d82ba --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/MusicControlEntityUpdateMessage.java @@ -0,0 +1,80 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +public class MusicControlEntityUpdateMessage extends GFDIMessage { + + private final Map attributes; + + public MusicControlEntityUpdateMessage(Map attributes) { + + this.attributes = attributes; + + } + + @Override + protected boolean generateOutgoing() { + final MessageWriter writer = new MessageWriter(response); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(GarminMessage.MUSIC_CONTROL_ENTITY_UPDATE.getId()); + + for (Map.Entry entry : attributes.entrySet()) { + MusicEntity a = entry.getKey(); + String value = entry.getValue(); + if (null == value) + value = ""; + byte[] v = value.getBytes(StandardCharsets.UTF_8); + if (v.length > 252) throw new IllegalArgumentException("Too long value"); + + writer.writeByte((v.length + 3) & 0xff); //the three following bytes + writer.writeByte(a.getEntityId()); + writer.writeByte(a.ordinal()); + writer.writeByte(0);//TODO what is this? + writer.writeBytes(v); + } + return true; + } + + public enum PLAYER implements MusicEntity { + NAME, + PLAYBACK_INFO, + VOLUME; + + @Override + public int getEntityId() { + return 0; + } + } + + + public enum QUEUE implements MusicEntity { + INDEX, + COUNT, + SHUFFLE, + REPEAT; + + @Override + public int getEntityId() { + return 1; + } + } + + public enum TRACK implements MusicEntity { + ARTIST, + ALBUM, + TITLE, + DURATION; + + @Override + public int getEntityId() { + return 2; + } + } + + public interface MusicEntity { + int getEntityId(); + + int ordinal(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/MusicControlMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/MusicControlMessage.java new file mode 100644 index 000000000..05d50569b --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/MusicControlMessage.java @@ -0,0 +1,45 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages; + + +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventMusicControl; + +public class MusicControlMessage extends GFDIMessage { + + private static final MusicControlCapabilitiesMessage.GarminMusicControlCommand[] commands = MusicControlCapabilitiesMessage.GarminMusicControlCommand.values(); + final int messageType; + private final GBDeviceEventMusicControl event; + + public MusicControlMessage(int messageType, MusicControlCapabilitiesMessage.GarminMusicControlCommand command) { + this.event = new GBDeviceEventMusicControl(); + this.messageType = messageType; + switch (command) { + case TOGGLE_PLAY_PAUSE: + event.event = GBDeviceEventMusicControl.Event.PLAYPAUSE; + break; + case SKIP_TO_NEXT_ITEM: + event.event = GBDeviceEventMusicControl.Event.NEXT; + break; + case SKIP_TO_PREVIOUS_ITEM: + event.event = GBDeviceEventMusicControl.Event.PREVIOUS; + break; + } + + this.statusMessage = this.getStatusMessage(messageType); + } + + public static MusicControlMessage parseIncoming(MessageReader reader, int messageType) { + MusicControlCapabilitiesMessage.GarminMusicControlCommand command = commands[reader.readByte()]; + + reader.warnIfLeftover(); + return new MusicControlMessage(messageType, command); + } + + public GBDeviceEventMusicControl getGBDeviceEvent() { + return event; + } + + @Override + protected boolean generateOutgoing() { + return false; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/ProtobufMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/ProtobufMessage.java new file mode 100644 index 000000000..9cd24455a --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/ProtobufMessage.java @@ -0,0 +1,89 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages; + + +import static nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.ProtobufStatusMessage.ProtobufStatusCode.NO_ERROR; + +public class ProtobufMessage extends GFDIMessage { + + + private final int requestId; + private final int messageType; + private final int dataOffset; + private final int totalProtobufLength; + private final int protobufDataLength; + private final byte[] messageBytes; + private final boolean sendOutgoing; + + public ProtobufMessage(int messageType, int requestId, int dataOffset, int totalProtobufLength, int protobufDataLength, byte[] messageBytes) { + this(messageType, requestId, dataOffset, totalProtobufLength, protobufDataLength, messageBytes, true); + } + + public ProtobufMessage(int messageType, int requestId, int dataOffset, int totalProtobufLength, int protobufDataLength, byte[] messageBytes, boolean sendOutgoing) { + this.messageType = messageType; + this.requestId = requestId; + this.dataOffset = dataOffset; + this.totalProtobufLength = totalProtobufLength; + this.protobufDataLength = protobufDataLength; + this.messageBytes = messageBytes; + this.sendOutgoing = sendOutgoing; + + if (isComplete()) { + this.statusMessage = new GenericStatusMessage(messageType, GFDIMessage.Status.ACK); + } else { + this.statusMessage = new ProtobufStatusMessage(messageType, GFDIMessage.Status.ACK, requestId, dataOffset, NO_ERROR, NO_ERROR); + } + } + + public static ProtobufMessage parseIncoming(MessageReader reader, int messageType) { + final int requestID = reader.readShort(); + final int dataOffset = reader.readInt(); + final int totalProtobufLength = reader.readInt(); + final int protobufDataLength = reader.readInt(); + final byte[] messageBytes = reader.readBytes(protobufDataLength); + + reader.warnIfLeftover(); + return new ProtobufMessage(messageType, requestID, dataOffset, totalProtobufLength, protobufDataLength, messageBytes, false); + } + + public int getRequestId() { + return requestId; + } + + public int getMessageType() { + return messageType; + } + + public int getDataOffset() { + return dataOffset; + } + + public int getTotalProtobufLength() { + return totalProtobufLength; + } + + public byte[] getMessageBytes() { + return messageBytes; + } + + public boolean isChunked() { + return (totalProtobufLength != protobufDataLength); + } + + public boolean isComplete() { + return (dataOffset == 0 && !isChunked()); + } + + @Override + protected boolean generateOutgoing() { + final MessageWriter writer = new MessageWriter(response); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(messageType); + writer.writeShort(requestId); + writer.writeInt(dataOffset); + writer.writeInt(totalProtobufLength); + writer.writeInt(protobufDataLength); + writer.writeBytes(messageBytes); + return sendOutgoing; + } + +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/ProtobufStatusMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/ProtobufStatusMessage.java new file mode 100644 index 000000000..b97fc0b82 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/ProtobufStatusMessage.java @@ -0,0 +1,115 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages; + +import androidx.annotation.Nullable; + +public class ProtobufStatusMessage extends GFDIStatusMessage { + + private final Status status; + private final int requestId; + private final int dataOffset; + private final ProtobufStatusCode protobufStatus; + private final ProtobufStatusCode error; //TODO: why is this duplicated? + private final int messageType; + private final boolean sendOutgoing; + + public ProtobufStatusMessage(int messageType, Status status, int requestId, int dataOffset, ProtobufStatusCode protobufStatus, ProtobufStatusCode error) { + this(messageType, status, requestId, dataOffset, protobufStatus, error, true); + } + public ProtobufStatusMessage(int messageType, Status status, int requestId, int dataOffset, ProtobufStatusCode protobufStatus, ProtobufStatusCode error, boolean sendOutgoing) { + this.messageType = messageType; + this.status = status; + this.requestId = requestId; + this.dataOffset = dataOffset; + this.protobufStatus = protobufStatus; + this.error = error; + this.sendOutgoing = sendOutgoing; + } + + public static ProtobufStatusMessage parseIncoming(MessageReader reader, int messageType) { + final Status status = Status.fromCode(reader.readByte()); + final int requestID = reader.readShort(); + final int dataOffset = reader.readInt(); + final ProtobufStatusCode protobufStatus = ProtobufStatusCode.fromCode(reader.readByte()); + final ProtobufStatusCode error = ProtobufStatusCode.fromCode(reader.readByte()); + + reader.warnIfLeftover(); + return new ProtobufStatusMessage(messageType, status, requestID, dataOffset, protobufStatus, error, false); + } + + public int getDataOffset() { + return dataOffset; + } + + public ProtobufStatusCode getProtobufStatus() { + return protobufStatus; + } + + public ProtobufStatusCode getError() { + return error; + } + + public int getMessageType() { + return messageType; + } + + public int getRequestId() { + return requestId; + } + + public boolean isOK() { + return this.status.equals(Status.ACK) && + this.protobufStatus.equals(ProtobufStatusCode.NO_ERROR) && + this.error.equals(ProtobufStatusCode.NO_ERROR); + } + + @Override + protected boolean generateOutgoing() { + final MessageWriter writer = new MessageWriter(response); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(GarminMessage.RESPONSE.getId()); + writer.writeShort(messageType); + writer.writeByte(status.ordinal()); + writer.writeShort(requestId); + writer.writeInt(dataOffset); + writer.writeByte(protobufStatus.code); + writer.writeByte(error.code); + return sendOutgoing; + } + + protected Status getStatus() { + return status; + } + + public enum ProtobufStatusCode { + NO_ERROR(0), + UNKNOWN_ERROR(1), + UNKNOWN_REQUEST_ID(100), + DUPLICATE_PACKET(101), + MISSING_PACKET(102), + EXCEEDED_TOTAL_PROTOBUF_LENGTH(103), + PROTOBUF_PARSE_ERROR(200), + ; + + private final int code; + + ProtobufStatusCode(final int code) { + this.code = code; + } + + @Nullable + public static ProtobufStatusCode fromCode(final int code) { + for (final ProtobufStatusCode protobufStatusCode : ProtobufStatusCode.values()) { + if (protobufStatusCode.getCode() == code) { + return protobufStatusCode; + } + } + return null; + } + + public int getCode() { + return code; + } + } + + +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/SetDeviceSettingsMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/SetDeviceSettingsMessage.java new file mode 100644 index 000000000..9d7f6c8e3 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/SetDeviceSettingsMessage.java @@ -0,0 +1,52 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages; + +import java.util.Map; + +public class SetDeviceSettingsMessage extends GFDIMessage { + private final Map settings; + + public SetDeviceSettingsMessage(Map settings) { + this.settings = settings; + final int settingsCount = settings.size(); + if (settingsCount == 0) throw new IllegalArgumentException("Empty settings"); + if (settingsCount > 255) throw new IllegalArgumentException("Too many settings"); + + } + + @Override + protected boolean generateOutgoing() { + final MessageWriter writer = new MessageWriter(response); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(GarminMessage.DEVICE_INFORMATION.getId()); + writer.writeByte(settings.size()); + for (Map.Entry settingPair : settings.entrySet()) { + final GarminDeviceSetting setting = settingPair.getKey(); + writer.writeByte(setting.ordinal()); + final Object value = settingPair.getValue(); + if (value instanceof String) { + writer.writeString((String) value); + } else if (value instanceof Integer) { + writer.writeByte(4); + writer.writeInt((Integer) value); + } else if (value instanceof Boolean) { + writer.writeByte(1); + writer.writeByte(Boolean.TRUE.equals(value) ? 1 : 0); + } else { + throw new IllegalArgumentException("Unsupported setting value type " + value); + } + } + return true; + } + + public enum GarminDeviceSetting { + DEVICE_NAME, + CURRENT_TIME, + DAYLIGHT_SAVINGS_TIME_OFFSET, + TIME_ZONE_OFFSET, + NEXT_DAYLIGHT_SAVINGS_START, + NEXT_DAYLIGHT_SAVINGS_END, + AUTO_UPLOAD_ENABLED, + WEATHER_CONDITIONS_ENABLED, + WEATHER_ALERTS_ENABLED + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/SystemEventMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/SystemEventMessage.java new file mode 100644 index 000000000..1bc87588c --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/SystemEventMessage.java @@ -0,0 +1,48 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages; + +public class SystemEventMessage extends GFDIMessage { + + private final GarminSystemEventType eventType; + private final Object value; + + public SystemEventMessage(GarminSystemEventType eventType, Object value) { + this.eventType = eventType; + this.value = value; + } + + @Override + protected boolean generateOutgoing() { + final MessageWriter writer = new MessageWriter(response); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(GarminMessage.SYSTEM_EVENT.getId()); + writer.writeByte(eventType.ordinal()); + if (value instanceof String) { + writer.writeString((String) value); + } else if (value instanceof Integer) { + writer.writeByte((Integer) value); + } else { + throw new IllegalArgumentException("Unsupported event value type " + value); + } + return true; + } + + public enum GarminSystemEventType { + SYNC_COMPLETE, + SYNC_FAIL, + FACTORY_RESET, + PAIR_START, + PAIR_COMPLETE, + PAIR_FAIL, + HOST_DID_ENTER_FOREGROUND, + HOST_DID_ENTER_BACKGROUND, + SYNC_READY, + NEW_DOWNLOAD_AVAILABLE, + DEVICE_SOFTWARE_UPDATE, + DEVICE_DISCONNECT, + TUTORIAL_COMPLETE, + SETUP_WIZARD_START, + SETUP_WIZARD_COMPLETE, + SETUP_WIZARD_SKIPPED, + TIME_UPDATED + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/UnhandledMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/UnhandledMessage.java new file mode 100644 index 000000000..32d108c92 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/UnhandledMessage.java @@ -0,0 +1,18 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages; + +public class UnhandledMessage extends GFDIMessage { + + private final int messageType; + + public UnhandledMessage(int messageType) { + this.messageType = messageType; + + this.statusMessage = new GenericStatusMessage(messageType, Status.UNSUPPORTED); + + } + + @Override + protected boolean generateOutgoing() { + return false; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/calendar/CalendarEvent.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/calendar/CalendarEvent.java index a085b91e3..2e8f6f5f0 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/calendar/CalendarEvent.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/calendar/CalendarEvent.java @@ -68,6 +68,10 @@ public class CalendarEvent { return end; } + public int getEndSeconds() { + return (int) (end / 1000); + } + public long getDuration() { return end - begin; } diff --git a/app/src/main/proto/garmin_vivomovehr/gdi_calendar_service.proto b/app/src/main/proto/garmin_vivomovehr/gdi_calendar_service.proto new file mode 100644 index 000000000..ceeb01176 --- /dev/null +++ b/app/src/main/proto/garmin_vivomovehr/gdi_calendar_service.proto @@ -0,0 +1,29 @@ +syntax = "proto2"; + +package garmin_vivomovehr; + +option java_package = "nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr"; + +message CalendarService { + optional CalendarServiceRequest calendar_request = 1; + optional CalendarServiceResponse calendar_response = 2; + + message CalendarServiceRequest { + optional uint32 begin = 1; + optional uint32 end = 2; + } + + message CalendarServiceResponse { + optional uint32 unknown = 1; + repeated CalendarEvent calendar_event = 2; + } + + message CalendarEvent { + optional string title = 2; + optional string location = 3 [default = ""]; + optional string description = 4 [default = ""]; + optional uint32 begin = 5; + optional uint32 end = 6; + optional bool all_day = 7; + } +} \ No newline at end of file diff --git a/app/src/main/proto/garmin_vivomovehr/gdi_core.proto b/app/src/main/proto/garmin_vivomovehr/gdi_core.proto index 411be5238..c53c24ce4 100644 --- a/app/src/main/proto/garmin_vivomovehr/gdi_core.proto +++ b/app/src/main/proto/garmin_vivomovehr/gdi_core.proto @@ -47,6 +47,14 @@ message CoreService { } } + enum DataType { + SIGNIFICANT_LOCATION = 0; + GENERAL_LOCATION = 1; + REALTIME_TRACKING = 2; + INREACH_TRACKING = 3; + TRACKING_EVENT = 4; + } + message LocationUpdatedSetEnabledRequest { optional bool enabled = 1; repeated Request requests = 2; @@ -56,17 +64,30 @@ message CoreService { optional DataType requested = 1; optional float min_update_threshold = 2; optional float distance_threshold = 3; - - enum DataType { - SIGNIFICANT_LOCATION = 0; - GENERAL_LOCATION = 1; - REALTIME_TRACKING = 2; - INREACH_TRACKING = 3; - } } message LocationUpdatedSetEnabledResponse { - // TODO + optional Status status = 1; + repeated Requested requests = 2; + + + enum Status { + OK = 1; + UNAVAILABLE = 2; + UNKNOWN3 = 3; + UNKNOWN4 = 4; + } + + message Requested { + optional DataType requested = 1; + optional RequestedStatus status = 2; + + enum RequestedStatus { + OK = 1; + KO = 2; + } + + } } message LocationUpdatedNotification { @@ -74,6 +95,19 @@ message CoreService { } message LocationData { - // TODO + required LatLon position=1; + required float altitude=2; + required uint32 timestamp=3; + required float h_accuracy=4; + required float v_accuracy=5; + required DataType position_type=6; + required float bearing=9; + required float speed=10; } + + message LatLon { + required sint32 lat = 1; + required sint32 lon = 2; + } + } diff --git a/app/src/main/proto/garmin_vivomovehr/gdi_smart_proto.proto b/app/src/main/proto/garmin_vivomovehr/gdi_smart_proto.proto index 64098539e..9218552af 100644 --- a/app/src/main/proto/garmin_vivomovehr/gdi_smart_proto.proto +++ b/app/src/main/proto/garmin_vivomovehr/gdi_smart_proto.proto @@ -8,8 +8,10 @@ import "garmin_vivomovehr/gdi_device_status.proto"; import "garmin_vivomovehr/gdi_find_my_watch.proto"; import "garmin_vivomovehr/gdi_core.proto"; import "garmin_vivomovehr/gdi_sms_notification.proto"; +import "garmin_vivomovehr/gdi_calendar_service.proto"; message Smart { + optional CalendarService calendar_service = 1; optional DeviceStatusService device_status_service = 8; optional FindMyWatchService find_my_watch_service = 12; optional CoreService core_service = 13; diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2549fd6d5..40bf97b2d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1481,7 +1481,9 @@ Amazfit GTS 2e Amazfit X Zepp E - Garmin Vivomove HR + Garmin Vivomove HR + Vívomove Style + Garmin Instinct 2S Vibratissimo UM-25 LiveView diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupportTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupportTest.java new file mode 100644 index 000000000..6983b3792 --- /dev/null +++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupportTest.java @@ -0,0 +1,100 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin; + +import org.junit.Assert; +import org.junit.Test; + +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.CobsCoDec; +import nodomain.freeyourgadget.gadgetbridge.util.GB; + + +public class GarminSupportTest { + //test strings from https://github.com/themarpe/cobs-java/blob/master/tests-java/Tests.java + static final byte[] test_string_0 = new byte[]{0, 0, 0, 0}; + static final byte[] test_string_1 = new byte[]{0, '1', '2', '3', '4', '5'}; + static final byte[] test_string_2 = new byte[]{0, '1', '2', '3', '4', '5', 0}; + static final byte[] test_string_3 = new byte[]{'1', '2', '3', '4', '5', 0, '6', '7', '8', '9'}; + static final byte[] test_string_4 = new byte[]{0, '1', '2', '3', '4', '5', 0, '6', '7', '8', '9', 0}; + static final byte[] test_string_5 = new byte[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, -128, -127, -126, -125, -124, -123, -122, -121, -120, -119, -118, -117, -116, -115, -114, -113, -112, -111, -110, -109, -108, -107, -106, -105, -104, -103, -102, -101, -100, -99, -98, -97, -96, -95, -94, -93, -92, -91, -90, -89, -88, -87, -86, -85, -84, -83, -82, -81, -80, -79, -78, -77, -76, -75, -74, -73, -72, -71, -70, -69, -68, -67, -66, -65, -64, -63, -62, -61, -60, -59, -58, -57, -56, -55, -54, -53, -52, -51, -50, -49, -48, -47, -46, -45, -44, -43, -42, -41, -40, -39, -38, -37, -36, -35, -34, -33, -32, -31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0}; + static final byte[] test_string_6 = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, -128, -127, -126, -125, -124, -123, -122, -121, -120, -119, -118, -117, -116, -115, -114, -113, -112, -111, -110, -109, -108, -107, -106, -105, -104, -103, -102, -101, -100, -99, -98, -97, -96, -95, -94, -93, -92, -91, -90, -89, -88, -87, -86, -85, -84, -83, -82, -81, -80, -79, -78, -77, -76, -75, -74, -73, -72, -71, -70, -69, -68, -67, -66, -65, -64, -63, -62, -61, -60, -59, -58, -57, -56, -55, -54, -53, -52, -51, -50, -49, -48, -47, -46, -45, -44, -43, -42, -41, -40, -39, -38, -37, -36, -35, -34, -33, -32, -31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0}; + static final byte[] test_string_7 = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, -128, -127, -126, -125, -124, -123, -122, -121, -120, -119, -118, -117, -116, -115, -114, -113, -112, -111, -110, -109, -108, -107, -106, -105, -104, -103, -102, -101, -100, -99, -98, -97, -96, -95, -94, -93, -92, -91, -90, -89, -88, -87, -86, -85, -84, -83, -82, -81, -80, -79, -78, -77, -76, -75, -74, -73, -72, -71, -70, -69, -68, -67, -66, -65, -64, -63, -62, -61, -60, -59, -58, -57, -56, -55, -54, -53, -52, -51, -50, -49, -48, -47, -46, -45, -44, -43, -42, -41, -40, -39, -38, -37, -36, -35, -34, -33, -32, -31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1}; + static final byte[] test_string_8 = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, -128, -127, -126, -125, -124, -123, -122, -121, -120, -119, -118, -117, -116, -115, -114, -113, -112, -111, -110, -109, -108, -107, -106, -105, -104, -103, -102, -101, -100, -99, -98, -97, -96, -95, -94, -93, -92, -91, -90, -89, -88, -87, -86, -85, -84, -83, -82, -81, -80, -79, -78, -77, -76, -75, -74, -73, -72, -71, -70, -69, -68, -67, -66, -65, -64, -63, -62, -61, -60, -59, -58, -57, -56, -55, -54, -53, -52, -51, -50, -49, -48, -47, -46, -45, -44, -43, -42, -41, -40, -39, -38, -37, -36, -35, -34, -33, -32, -31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, -128, -127, -126, -125, -124, -123, -122, -121, -120, -119, -118, -117, -116, -115, -114, -113, -112, -111, -110, -109, -108, -107, -106, -105, -104, -103, -102, -101, -100, -99, -98, -97, -96, -95, -94, -93, -92, -91, -90, -89, -88, -87, -86, -85, -84, -83, -82, -81, -80, -79, -78, -77, -76, -75, -74, -73, -72, -71, -70, -69, -68, -67, -66, -65, -64, -63, -62, -61, -60, -59, -58, -57, -56, -55, -54, -53, -52, -51, -50, -49, -48, -47, -46, -45, -44, -43, -42, -41, -40, -39, -38, -37, -36, -35, -34, -33, -32, -31, -30, -29, -28, -27, -26, -25, -24, -23, -22, -21, -20, -19, -18, -17, -16, -15, -14, -13, -12, -11, -10, -9, -8, -7, -6, 0, -5, -4, -3, -2, -1}; + final CobsCoDec cobsCoDec = new CobsCoDec(); + final byte[][] allTests = new byte[][]{test_string_1, test_string_2, test_string_3, test_string_4, test_string_5, test_string_6, test_string_7, test_string_8}; + + @Test + public void testCobsDecoder() { + + cobsCoDec.receivedBytes(GB.hexStringToByteArray("00022C04A0139623310F684C1BCA840508020B")); + Assert.assertNull(cobsCoDec.retrieveMessage()); + cobsCoDec.receivedBytes(GB.hexStringToByteArray("496E7374696E637420325308496E7374696E63")); + Assert.assertNull(cobsCoDec.retrieveMessage()); + cobsCoDec.receivedBytes(GB.hexStringToByteArray("74023253010304B800")); + Assert.assertArrayEquals(GB.hexStringToByteArray("2C00A0139600310F684C1BCA840508020B496E7374696E637420325308496E7374696E6374023253000004B8"), + cobsCoDec.retrieveMessage()); + } + + @Test + public void testCobsDecoder2() { + + cobsCoDec.receivedBytes(GB.hexStringToByteArray("00022b058813a013029623ffffffffffffa71fffff046c61726a07756e6b6e6f776e0758512d4343373201f9cf00")); + Assert.assertArrayEquals(new byte[]{0x2b, 0x00, (byte) 0x88, 0x13, (byte) 0xa0, 0x13, 0x00, (byte) 0x96, 0x00, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xa7, 0x1f, (byte) 0xff, (byte) 0xff, 0x04, 0x6c, 0x61, 0x72, 0x6a, 0x07, 0x75, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x07, 0x58, 0x51, 0x2d, 0x43, 0x43, 0x37, 0x32, 0x01, (byte) 0xf9, (byte) 0xcf}, + cobsCoDec.retrieveMessage()); + } + + @Test + public void testCobsEncoder2() { + + byte[] result = cobsCoDec.encode(GB.hexStringToByteArray("022b058813a013029623ffffffffffffa71fffff046c61726a07756e6b6e6f776e0758512d4343373201f9cf00")); + Assert.assertArrayEquals(new byte[]{0x00, 0x2d, (byte) 0x02, (byte) 0x2b, (byte) 0x05, (byte) 0x88, (byte) 0x13, (byte) 0xa0, (byte) 0x13, (byte) 0x02, (byte) 0x96, (byte) 0x23, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xa7, (byte) 0x1f, (byte) 0xff, (byte) 0xff, (byte) 0x04, (byte) 0x6c, (byte) 0x61, (byte) 0x72, (byte) 0x6a, (byte) 0x07, (byte) 0x75, (byte) 0x6e, (byte) 0x6b, (byte) 0x6e, (byte) 0x6f, (byte) 0x77, (byte) 0x6e, (byte) 0x07, (byte) 0x58, (byte) 0x51, (byte) 0x2d, (byte) 0x43, (byte) 0x43, (byte) 0x37, (byte) 0x32, (byte) 0x01, (byte) 0xf9, (byte) 0xcf, (byte) 0x01, (byte) 0x00}, + result); + } + + + @Test + public void testCobsDecoderSingleByteAtStart() { + + cobsCoDec.receivedBytes(GB.hexStringToByteArray("00")); + Assert.assertNull(cobsCoDec.retrieveMessage()); + cobsCoDec.receivedBytes(GB.hexStringToByteArray("022C04A0139623310F684C1BCA840508020B")); + Assert.assertNull(cobsCoDec.retrieveMessage()); + cobsCoDec.receivedBytes(GB.hexStringToByteArray("496E7374696E637420325308496E7374696E63")); + Assert.assertNull(cobsCoDec.retrieveMessage()); + cobsCoDec.receivedBytes(GB.hexStringToByteArray("74023253010304B800")); + Assert.assertArrayEquals(GB.hexStringToByteArray("2C00A0139600310F684C1BCA840508020B496E7374696E637420325308496E7374696E6374023253000004B8"), + cobsCoDec.retrieveMessage()); + } + + @Test + public void testCobsDecoderSingleByteAtEnd() { + + cobsCoDec.receivedBytes(GB.hexStringToByteArray("00022C04A0139623310F684C1BCA840508020B")); + Assert.assertNull(cobsCoDec.retrieveMessage()); + cobsCoDec.receivedBytes(GB.hexStringToByteArray("496E7374696E637420325308496E7374696E63")); + Assert.assertNull(cobsCoDec.retrieveMessage()); + cobsCoDec.receivedBytes(GB.hexStringToByteArray("74023253010304B8")); + Assert.assertNull(cobsCoDec.retrieveMessage()); + cobsCoDec.receivedBytes(GB.hexStringToByteArray("00")); + Assert.assertArrayEquals(GB.hexStringToByteArray("2C00A0139600310F684C1BCA840508020B496E7374696E637420325308496E7374696E6374023253000004B8"), + cobsCoDec.retrieveMessage()); + } + + @Test + public void testCobsEncoder() { + Assert.assertArrayEquals(GB.hexStringToByteArray("00022C04A0139623310F684C1BCA840508020B496E7374696E637420325308496E7374696E6374023253010304B800"), + cobsCoDec.encode(GB.hexStringToByteArray("2C00A0139600310F684C1BCA840508020B496E7374696E637420325308496E7374696E6374023253000004B8"))); + + } + + @Test + public void testLongPayload() { + + for (byte[] payload : allTests) { + byte[] encodedData = cobsCoDec.encode(payload); + cobsCoDec.receivedBytes(encodedData); + byte[] decodedData = cobsCoDec.retrieveMessage(); + + Assert.assertArrayEquals(payload, decodedData); + } + } + +}