From 3a58314db66aa41043d34b4c30f779f202613ad0 Mon Sep 17 00:00:00 2001 From: Mormegil Date: Thu, 15 Jun 2023 17:47:42 +0200 Subject: [PATCH] Garmin Vivomove HR support - communication protocols - device support implementation - download FIT file storage Features: - basic connectivity: time sync, battery status, HW/FW version info - real-time activity tracking - fitness data sync - find the device, find the phone - factory reset Features implemented but not working: - notifications: fully implemented, seem to communicate correctly, but not shown on watch Features implemented partially (not expected to work now): - weather information (and in future possibly weather alerts) - music info - firmware update: only the initial file upload implemented, not used Things to improve/change: - Device name hardcoded in `VivomoveHrCoordinator.getSupportedType`, service UUIDs not available - Download FIT file storage: Should be store (and offer the user to export?) the FIT data forever? - Obviously, various code improvements, cleanup, etc. --- .../gadgetbridge/daogen/GBDaoGenerator.java | 30 + .../vivomovehr/VivomoveHrCoordinator.java | 2 +- .../externalevents/NotificationListener.java | 12 + .../gadgetbridge/model/NotificationSpec.java | 4 +- .../service/DeviceSupportFactory.java | 14 +- .../devices/vivomovehr/BinaryUtils.java | 50 + .../vivomovehr/ChecksumCalculator.java | 22 + .../vivomovehr/GarminDeviceSetting.java | 13 + .../devices/vivomovehr/GarminMessageType.java | 28 + .../vivomovehr/GarminMusicControlCommand.java | 13 + .../vivomovehr/GarminSystemEventType.java | 21 + .../devices/vivomovehr/GarminTimeUtils.java | 24 + .../devices/vivomovehr/GfdiPacketParser.java | 175 +++ .../vivomovehr/RealTimeActivityHandler.java | 166 +++ .../vivomovehr/VivomoveHrCommunicator.java | 91 ++ .../devices/vivomovehr/VivomoveHrSupport.java | 1059 +++++++++++++++++ .../devices/vivomovehr/ams/AmsEntity.java | 7 + .../vivomovehr/ams/AmsEntityAttribute.java | 42 + .../devices/vivomovehr/ancs/AncsAction.java | 6 + .../vivomovehr/ancs/AncsAndroidAction.java | 32 + .../vivomovehr/ancs/AncsAppAttribute.java | 5 + .../vivomovehr/ancs/AncsAttribute.java | 49 + .../vivomovehr/ancs/AncsAttributeRequest.java | 11 + .../devices/vivomovehr/ancs/AncsCategory.java | 17 + .../devices/vivomovehr/ancs/AncsCommand.java | 31 + .../vivomovehr/ancs/AncsControlCommand.java | 121 ++ .../devices/vivomovehr/ancs/AncsEvent.java | 7 + .../vivomovehr/ancs/AncsEventFlag.java | 9 + .../ancs/AncsGetAppAttributesCommand.java | 14 + .../AncsGetNotificationAttributeCommand.java | 14 + ...AncsGetNotificationAttributesResponse.java | 23 + .../ancs/AncsPerformAndroidAction.java | 14 + .../ancs/AncsPerformNotificationAction.java | 12 + .../vivomovehr/ancs/GncsDataSourceQueue.java | 97 ++ .../vivomovehr/downloads/DirectoryData.java | 38 + .../vivomovehr/downloads/DirectoryEntry.java | 25 + .../downloads/FileDownloadListener.java | 9 + .../downloads/FileDownloadQueue.java | 172 +++ .../devices/vivomovehr/fit/FitBool.java | 7 + .../devices/vivomovehr/fit/FitDbImporter.java | 54 + .../vivomovehr/fit/FitFieldBaseType.java | 53 + .../vivomovehr/fit/FitImportProcessor.java | 7 + .../devices/vivomovehr/fit/FitImporter.java | 272 +++++ .../fit/FitLocalFieldDefinition.java | 13 + .../fit/FitLocalMessageDefinition.java | 13 + .../devices/vivomovehr/fit/FitMessage.java | 165 +++ .../vivomovehr/fit/FitMessageDefinition.java | 55 + .../vivomovehr/fit/FitMessageDefinitions.java | 646 ++++++++++ .../fit/FitMessageFieldDefinition.java | 35 + .../devices/vivomovehr/fit/FitParser.java | 285 +++++ .../devices/vivomovehr/fit/FitSerializer.java | 258 ++++ .../vivomovehr/fit/FitWeatherConditions.java | 191 +++ .../messages/AuthNegotiationMessage.java | 29 + .../AuthNegotiationResponseMessage.java | 26 + .../messages/BatteryStatusMessage.java | 23 + .../messages/ConfigurationMessage.java | 34 + .../messages/CreateFileRequestMessage.java | 28 + .../messages/CreateFileResponseMessage.java | 38 + .../messages/CurrentTimeRequestMessage.java | 16 + .../CurrentTimeRequestResponseMessage.java | 28 + .../messages/DeviceInformationMessage.java | 46 + .../DeviceInformationResponseMessage.java | 31 + .../DirectoryFileFilterRequestMessage.java | 26 + .../DirectoryFileFilterResponseMessage.java | 23 + .../messages/DownloadRequestMessage.java | 28 + .../DownloadRequestResponseMessage.java | 31 + .../vivomovehr/messages/FileReadyMessage.java | 44 + .../messages/FileTransferDataMessage.java | 46 + .../FileTransferDataResponseMessage.java | 48 + .../messages/FindMyPhoneRequestMessage.java | 17 + .../vivomovehr/messages/FitDataMessage.java | 24 + .../messages/FitDataResponseMessage.java | 27 + .../messages/FitDefinitionMessage.java | 24 + .../FitDefinitionResponseMessage.java | 27 + .../messages/GenericResponseMessage.java | 22 + .../messages/GncsControlPointMessage.java | 17 + .../GncsControlPointResponseMessage.java | 34 + .../messages/GncsDataSourceMessage.java | 25 + .../GncsDataSourceResponseMessage.java | 26 + .../GncsNotificationSourceMessage.java | 35 + .../vivomovehr/messages/MessageReader.java | 84 ++ .../vivomovehr/messages/MessageWriter.java | 77 ++ .../MusicControlCapabilitiesMessage.java | 16 + ...sicControlCapabilitiesResponseMessage.java | 28 + .../MusicControlEntityUpdateMessage.java | 24 + .../devices/vivomovehr/messages/NOTES.txt | 144 +++ ...otificationServiceSubscriptionMessage.java | 32 + ...ionServiceSubscriptionResponseMessage.java | 25 + .../messages/ProtobufRequestMessage.java | 46 + .../ProtobufRequestResponseMessage.java | 37 + .../vivomovehr/messages/RequestMessage.java | 24 + .../vivomovehr/messages/ResponseMessage.java | 42 + .../messages/SetDeviceSettingsMessage.java | 44 + .../SetDeviceSettingsResponseMessage.java | 20 + .../SupportedFileTypesRequestMessage.java | 20 + .../SupportedFileTypesResponseMessage.java | 48 + .../messages/SyncRequestMessage.java | 45 + .../messages/SystemEventMessage.java | 29 + .../messages/SystemEventResponseMessage.java | 20 + .../messages/UploadRequestMessage.java | 24 + .../UploadRequestResponseMessage.java | 36 + .../messages/WeatherRequestMessage.java | 25 + .../WeatherRequestResponseMessage.java | 25 + .../notifications/NotificationData.java | 150 +++ .../notifications/NotificationStorage.java | 101 ++ .../vivomovehr/uploads/FileUploadQueue.java | 199 ++++ .../gadgetbridge/util/ArrayUtils.java | 29 +- .../proto/garmin_vivomovehr/gdi_core.proto | 79 ++ .../garmin_vivomovehr/gdi_device_status.proto | 44 + .../garmin_vivomovehr/gdi_find_my_watch.proto | 33 + .../garmin_vivomovehr/gdi_smart_proto.proto | 53 + .../gdi_sms_notification.proto | 50 + 112 files changed, 6998 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/BinaryUtils.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ChecksumCalculator.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminDeviceSetting.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminMessageType.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminMusicControlCommand.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminSystemEventType.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminTimeUtils.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GfdiPacketParser.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/RealTimeActivityHandler.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/VivomoveHrCommunicator.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/VivomoveHrSupport.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ams/AmsEntity.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ams/AmsEntityAttribute.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAction.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAndroidAction.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAppAttribute.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAttribute.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAttributeRequest.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsCategory.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsCommand.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsControlCommand.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsEvent.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsEventFlag.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsGetAppAttributesCommand.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsGetNotificationAttributeCommand.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsGetNotificationAttributesResponse.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsPerformAndroidAction.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsPerformNotificationAction.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/GncsDataSourceQueue.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/downloads/DirectoryData.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/downloads/DirectoryEntry.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/downloads/FileDownloadListener.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/downloads/FileDownloadQueue.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitBool.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitDbImporter.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitFieldBaseType.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitImportProcessor.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitImporter.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitLocalFieldDefinition.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitLocalMessageDefinition.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitMessageDefinition.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitMessageDefinitions.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitMessageFieldDefinition.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitParser.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitSerializer.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitWeatherConditions.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/AuthNegotiationMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/AuthNegotiationResponseMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/BatteryStatusMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/ConfigurationMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/CreateFileRequestMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/CreateFileResponseMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/CurrentTimeRequestMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/CurrentTimeRequestResponseMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/DeviceInformationMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/DeviceInformationResponseMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/DirectoryFileFilterRequestMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/DirectoryFileFilterResponseMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/DownloadRequestMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/DownloadRequestResponseMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FileReadyMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FileTransferDataMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FileTransferDataResponseMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FindMyPhoneRequestMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FitDataMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FitDataResponseMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FitDefinitionMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FitDefinitionResponseMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/GenericResponseMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/GncsControlPointMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/GncsControlPointResponseMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/GncsDataSourceMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/GncsDataSourceResponseMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/GncsNotificationSourceMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/MessageReader.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/MessageWriter.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/MusicControlCapabilitiesMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/MusicControlCapabilitiesResponseMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/MusicControlEntityUpdateMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/NOTES.txt create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/NotificationServiceSubscriptionMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/NotificationServiceSubscriptionResponseMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/ProtobufRequestMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/ProtobufRequestResponseMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/RequestMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/ResponseMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/SetDeviceSettingsMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/SetDeviceSettingsResponseMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/SupportedFileTypesRequestMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/SupportedFileTypesResponseMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/SyncRequestMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/SystemEventMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/SystemEventResponseMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/UploadRequestMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/UploadRequestResponseMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/WeatherRequestMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/WeatherRequestResponseMessage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/notifications/NotificationData.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/notifications/NotificationStorage.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/uploads/FileUploadQueue.java create mode 100644 app/src/main/proto/garmin_vivomovehr/gdi_core.proto create mode 100644 app/src/main/proto/garmin_vivomovehr/gdi_device_status.proto create mode 100644 app/src/main/proto/garmin_vivomovehr/gdi_find_my_watch.proto create mode 100644 app/src/main/proto/garmin_vivomovehr/gdi_smart_proto.proto create mode 100644 app/src/main/proto/garmin_vivomovehr/gdi_sms_notification.proto diff --git a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java index 8c357d15a..bde2a7405 100644 --- a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java +++ b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java @@ -92,6 +92,7 @@ public class GBDaoGenerator { addPineTimeActivitySample(schema, user, device); addHybridHRActivitySample(schema, user, device); addVivomoveHrActivitySample(schema, user, device); + addDownloadedFitFile(schema, user, device); addCalendarSyncState(schema, device); addAlarms(schema, user, device); @@ -475,6 +476,35 @@ public class GBDaoGenerator { return activitySample; } + private static Entity addDownloadedFitFile(Schema schema, Entity user, Entity device) { + final Entity downloadedFitFile = addEntity(schema, "DownloadedFitFile"); + downloadedFitFile.implementsSerializable(); + downloadedFitFile.setJavaDoc("This class represents a single FIT file downloaded from a FIT-compatible device."); + downloadedFitFile.addIdProperty().autoincrement(); + downloadedFitFile.addLongProperty("downloadTimestamp").notNull(); + final Property deviceId = downloadedFitFile.addLongProperty("deviceId").notNull().getProperty(); + downloadedFitFile.addToOne(device, deviceId); + final Property userId = downloadedFitFile.addLongProperty("userId").notNull().getProperty(); + downloadedFitFile.addToOne(user, userId); + final Property fileNumber = downloadedFitFile.addIntProperty("fileNumber").notNull().getProperty(); + downloadedFitFile.addIntProperty("fileDataType").notNull(); + downloadedFitFile.addIntProperty("fileSubType").notNull(); + downloadedFitFile.addLongProperty("fileTimestamp").notNull(); + downloadedFitFile.addIntProperty("specificFlags").notNull(); + downloadedFitFile.addIntProperty("fileSize").notNull(); + downloadedFitFile.addByteArrayProperty("fileData"); + + final Index indexUnique = new Index(); + indexUnique.addProperty(deviceId); + indexUnique.addProperty(userId); + indexUnique.addProperty(fileNumber); + indexUnique.makeUnique(); + + downloadedFitFile.addIndex(indexUnique); + + return downloadedFitFile; + } + private static Entity addWatchXPlusHealthActivitySample(Schema schema, Entity user, Entity device) { Entity activitySample = addEntity(schema, "WatchXPlusActivitySample"); activitySample.implementsSerializable(); 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 d52b5612a..211d67c35 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 @@ -63,7 +63,7 @@ public class VivomoveHrCoordinator extends AbstractDeviceCoordinator { } @Override - public int getAlarmSlotCount() { + public int getAlarmSlotCount(GBDevice device) { return 0; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/NotificationListener.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/NotificationListener.java index 779b77043..6760869c9 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/NotificationListener.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/NotificationListener.java @@ -81,6 +81,17 @@ import nodomain.freeyourgadget.gadgetbridge.util.MediaManager; import nodomain.freeyourgadget.gadgetbridge.util.NotificationUtils; import nodomain.freeyourgadget.gadgetbridge.util.PebbleUtils; import nodomain.freeyourgadget.gadgetbridge.util.Prefs; +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.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.TimeUnit; import static nodomain.freeyourgadget.gadgetbridge.activities.NotificationFilterActivity.NOTIFICATION_FILTER_MODE_BLACKLIST; import static nodomain.freeyourgadget.gadgetbridge.activities.NotificationFilterActivity.NOTIFICATION_FILTER_MODE_WHITELIST; @@ -333,6 +344,7 @@ public class NotificationListener extends NotificationListenerService { } NotificationSpec notificationSpec = new NotificationSpec(); + notificationSpec.when = notification.when; // determinate Source App Name ("Label") String name = getAppName(source); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/NotificationSpec.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/NotificationSpec.java index 6bfcf55dc..b7c1e3a84 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/NotificationSpec.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/NotificationSpec.java @@ -24,6 +24,7 @@ public class NotificationSpec { public int flags; private static final AtomicInteger c = new AtomicInteger((int) (System.currentTimeMillis()/1000)); private int id; + public long when; public String sender; public String phoneNumber; public String title; @@ -53,7 +54,7 @@ public class NotificationSpec { public int dndSuppressed; public NotificationSpec() { - this.id = c.incrementAndGet(); + this(-1); } public NotificationSpec(int id) { @@ -61,6 +62,7 @@ public class NotificationSpec { this.id = id; else this.id = c.incrementAndGet(); + this.when = System.currentTimeMillis(); } public int getId() { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceSupportFactory.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceSupportFactory.java index 60f554068..d23138ace 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceSupportFactory.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceSupportFactory.java @@ -25,25 +25,20 @@ package nodomain.freeyourgadget.gadgetbridge.service; import android.bluetooth.BluetoothAdapter; import android.content.Context; import android.widget.Toast; - -import java.lang.reflect.Constructor; -import java.util.EnumSet; - import nodomain.freeyourgadget.gadgetbridge.GBException; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.DeviceType; import nodomain.freeyourgadget.gadgetbridge.service.devices.asteroidos.AsteroidOSDeviceSupport; -import nodomain.freeyourgadget.gadgetbridge.service.devices.binary_sensor.BinarySensorSupport; -import nodomain.freeyourgadget.gadgetbridge.service.devices.fitpro.FitProDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.banglejs.BangleJSDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.binary_sensor.BinarySensorSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.casio.CasioGB6900DeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.casio.CasioGBX100DeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.domyos.DomyosT540Support; +import nodomain.freeyourgadget.gadgetbridge.service.devices.fitpro.FitProDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.flipper.zero.support.FlipperZeroSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.galaxy_buds.GalaxyBudsDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.hplus.HPlusSupport; -import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitband5.AmazfitBand5Support; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitband7.AmazfitBand7Support; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitbip.AmazfitBipLiteSupport; @@ -112,12 +107,15 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.tlw64.TLW64Support; import nodomain.freeyourgadget.gadgetbridge.service.devices.um25.Support.UM25Support; import nodomain.freeyourgadget.gadgetbridge.service.devices.vesc.VescDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.vibratissimo.VibratissimoSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.VivomoveHrSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.waspos.WaspOSDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.watch9.Watch9DeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.xwatch.XWatchSupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.zetime.ZeTimeDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.util.GB; +import java.lang.reflect.Constructor; + public class DeviceSupportFactory { private final BluetoothAdapter mBtAdapter; private final Context mContext; @@ -372,6 +370,8 @@ public class DeviceSupportFactory { return new ServiceDeviceSupport(new AsteroidOSDeviceSupport()); case SOFLOW_SO6: return new ServiceDeviceSupport(new SoFlowSupport()); + case VIVOMOVE_HR: + return new ServiceDeviceSupport(new VivomoveHrSupport()); } return null; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/BinaryUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/BinaryUtils.java new file mode 100644 index 000000000..040b2d1ed --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/BinaryUtils.java @@ -0,0 +1,50 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr; + +public final class BinaryUtils { + private BinaryUtils() { + } + + public static int readByte(byte[] array, int offset) { + return array[offset] & 0xFF; + } + + public static int readShort(byte[] array, int offset) { + return (array[offset] & 0xFF) | ((array[offset + 1] & 0xFF) << 8); + } + + public static int readInt(byte[] array, int offset) { + return (array[offset] & 0xFF) | ((array[offset + 1] & 0xFF) << 8) | ((array[offset + 2] & 0xFF) << 16) | ((array[offset + 3] & 0xFF) << 24); + } + + public static long readLong(byte[] array, int offset) { + return (array[offset] & 0xFFL) | ((array[offset + 1] & 0xFFL) << 8) | ((array[offset + 2] & 0xFFL) << 16) | ((array[offset + 3] & 0xFFL) << 24) | + ((array[offset + 4] & 0xFFL) << 32) | ((array[offset + 5] & 0xFFL) << 40) | ((array[offset + 6] & 0xFFL) << 48) | ((array[offset + 7] & 0xFFL) << 56); + } + + public static void writeByte(byte[] array, int offset, int value) { + array[offset] = (byte) value; + } + + public static void writeShort(byte[] array, int offset, int value) { + array[offset] = (byte) value; + array[offset + 1] = (byte) (value >> 8); + } + + public static void writeInt(byte[] array, int offset, int value) { + array[offset] = (byte) value; + array[offset + 1] = (byte) (value >> 8); + array[offset + 2] = (byte) (value >> 16); + array[offset + 3] = (byte) (value >> 24); + } + + public static void writeLong(byte[] array, int offset, long value) { + array[offset] = (byte) value; + array[offset + 1] = (byte) (value >> 8); + array[offset + 2] = (byte) (value >> 16); + array[offset + 3] = (byte) (value >> 24); + array[offset + 4] = (byte) (value >> 32); + array[offset + 5] = (byte) (value >> 40); + array[offset + 6] = (byte) (value >> 48); + array[offset + 7] = (byte) (value >> 56); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ChecksumCalculator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ChecksumCalculator.java new file mode 100644 index 000000000..e695c9470 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ChecksumCalculator.java @@ -0,0 +1,22 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr; + +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(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/vivomovehr/GarminDeviceSetting.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminDeviceSetting.java new file mode 100644 index 000000000..288a48eaa --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminDeviceSetting.java @@ -0,0 +1,13 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr; + +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/vivomovehr/GarminMessageType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminMessageType.java new file mode 100644 index 000000000..fef0e50d8 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminMessageType.java @@ -0,0 +1,28 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr; + +public enum GarminMessageType { + SCHEDULES, + SETTINGS, + GOALS, + WORKOUTS, + COURSES, + ACTIVITIES, + PERSONAL_RECORDS, + UNKNOWN_TYPE, + SOFTWARE_UPDATE, + DEVICE_SETTINGS, + LANGUAGE_SETTINGS, + USER_PROFILE, + SPORTS, + SEGMENT_LEADERS, + GOLF_CLUB, + WELLNESS_DEVICE_INFO, + WELLNESS_DEVICE_CCF, + INSTALL_APP, + CHECK_BACK, + TRUE_UP, + SETTINGS_CHANGE, + ACTIVITY_SUMMARY, + METRICS_FILE, + PACE_BAND +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminMusicControlCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminMusicControlCommand.java new file mode 100644 index 000000000..0e86765fb --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminMusicControlCommand.java @@ -0,0 +1,13 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr; + +public 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/vivomovehr/GarminSystemEventType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminSystemEventType.java new file mode 100644 index 000000000..256261d05 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminSystemEventType.java @@ -0,0 +1,21 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr; + +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/vivomovehr/GarminTimeUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminTimeUtils.java new file mode 100644 index 000000000..08983d73b --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GarminTimeUtils.java @@ -0,0 +1,24 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; + +public final class GarminTimeUtils { + private GarminTimeUtils() { + } + + public static int unixTimeToGarminTimestamp(int unixTime) { + return unixTime - VivomoveConstants.GARMIN_TIME_EPOCH; + } + + public static int javaMillisToGarminTimestamp(long millis) { + return (int) (millis / 1000) - VivomoveConstants.GARMIN_TIME_EPOCH; + } + + public static long garminTimestampToJavaMillis(int timestamp) { + return (timestamp + VivomoveConstants.GARMIN_TIME_EPOCH) * 1000L; + } + + public static int garminTimestampToUnixTime(int timestamp) { + return timestamp + VivomoveConstants.GARMIN_TIME_EPOCH; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GfdiPacketParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GfdiPacketParser.java new file mode 100644 index 000000000..8c5b670e3 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/GfdiPacketParser.java @@ -0,0 +1,175 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Arrays; + +/** + * Parser of GFDI messages embedded in COBS packets. + *

+ * COBS ensures there are no embedded NUL bytes inside the packet data, and wraps the message into NUL framing bytes. + */ +// Notes: not really optimized; does a lot of (re)allocation, might use more static buffers, I guess… And code cleanup as well. +public class GfdiPacketParser { + private static final Logger LOG = LoggerFactory.getLogger(GfdiPacketParser.class); + + private static final long BUFFER_TIMEOUT = 1500L; + private static final byte[] EMPTY_BUFFER = new byte[0]; + private static final byte[] BUFFER_FRAMING = new byte[1]; + + private byte[] buffer = EMPTY_BUFFER; + private byte[] packet; + private byte[] packetBuffer; + private int bufferPos; + private long lastUpdate; + private boolean insidePacket; + + public void reset() { + buffer = EMPTY_BUFFER; + bufferPos = 0; + insidePacket = false; + packet = null; + packetBuffer = EMPTY_BUFFER; + } + + public void receivedBytes(byte[] bytes) { + final long now = System.currentTimeMillis(); + if ((now - lastUpdate) > BUFFER_TIMEOUT) { + reset(); + } + lastUpdate = now; + final int bufferSize = buffer.length; + buffer = Arrays.copyOf(buffer, bufferSize + bytes.length); + System.arraycopy(bytes, 0, buffer, bufferSize, bytes.length); + parseBuffer(); + } + + public byte[] retrievePacket() { + final byte[] resultPacket = packet; + packet = null; + parseBuffer(); + return resultPacket; + } + + private void parseBuffer() { + if (packet != null) { + // packet is waiting, unable to parse more + return; + } + if (bufferPos >= buffer.length) { + // nothing to parse + return; + } + boolean startOfPacket = !insidePacket; + if (startOfPacket) { + byte b; + while (bufferPos < buffer.length && (b = buffer[bufferPos++]) != 0) { + if (LOG.isDebugEnabled()) { + LOG.debug("Unexpected non-zero byte while looking for framing: {}", Integer.toHexString(b)); + } + } + if (bufferPos >= buffer.length) { + // nothing to parse + return; + } + insidePacket = true; + } + boolean endedWithFullChunk = false; + while (bufferPos < buffer.length) { + int chunkSize = -1; + int chunkStart = bufferPos; + int pos = bufferPos; + while (pos < buffer.length && ((chunkSize = (buffer[pos++] & 0xFF)) == 0) && startOfPacket) { + // skip repeating framing bytes (?) + bufferPos = pos; + chunkStart = pos; + } + if (startOfPacket && pos >= buffer.length) { + // incomplete framing, needs to wait for more data and try again + buffer = BUFFER_FRAMING; + bufferPos = 0; + insidePacket = false; + return; + } + assert chunkSize >= 0; + if (chunkSize == 0) { + // end of packet + // drop the last zero + if (endedWithFullChunk) { + // except when it was explicitly added (TODO: ugly, is it correct?) + packet = packetBuffer; + } else { + packet = Arrays.copyOf(packetBuffer, packetBuffer.length - 1); + } + packetBuffer = EMPTY_BUFFER; + insidePacket = false; + + if (bufferPos == buffer.length - 1) { + buffer = EMPTY_BUFFER; + bufferPos = 0; + } else { + // TODO: Realloc buffer down + ++bufferPos; + } + return; + } + if (chunkStart + chunkSize > buffer.length) { + // incomplete chunk, needs to wait for more data + return; + } + + // completed chunk + final int packetPos = packetBuffer.length; + final int realChunkSize = chunkSize < 255 ? chunkSize : chunkSize - 1; + packetBuffer = Arrays.copyOf(packetBuffer, packetPos + realChunkSize); + System.arraycopy(buffer, chunkStart + 1, packetBuffer, packetPos, chunkSize - 1); + bufferPos = chunkStart + chunkSize; + + endedWithFullChunk = chunkSize == 255; + startOfPacket = false; + } + } + + public static byte[] wrapMessageToPacket(byte[] message) { + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(message.length + 2 + (message.length + 253) / 254)) { + outputStream.write(0); + int chunkStart = 0; + for (int i = 0; i < message.length; ++i) { + if (message[i] == 0) { + chunkStart = appendChunk(message, outputStream, chunkStart, i); + } + } + if (chunkStart <= message.length) { + appendChunk(message, outputStream, chunkStart, message.length); + } + outputStream.write(0); + return outputStream.toByteArray(); + } catch (IOException e) { + LOG.error("Error writing to memory buffer", e); + throw new RuntimeException(e); + } + } + + private static int appendChunk(byte[] message, ByteArrayOutputStream outputStream, int chunkStart, int messagePos) { + int chunkLength = messagePos - chunkStart; + while (true) { + if (chunkLength >= 255) { + // write 255-byte chunk + outputStream.write(255); + outputStream.write(message, chunkStart, 254); + chunkLength -= 254; + chunkStart += 254; + } else { + // write chunk from chunkStart to here + outputStream.write(chunkLength + 1); + outputStream.write(message, chunkStart, chunkLength); + chunkStart = messagePos + 1; + break; + } + } + return chunkStart; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/RealTimeActivityHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/RealTimeActivityHandler.java new file mode 100644 index 000000000..39a41f51b --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/RealTimeActivityHandler.java @@ -0,0 +1,166 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr; + +import android.content.Intent; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; +import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveHrSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.Device; +import nodomain.freeyourgadget.gadgetbridge.entities.User; +import nodomain.freeyourgadget.gadgetbridge.entities.VivomoveHrActivitySample; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample; +import nodomain.freeyourgadget.gadgetbridge.model.DeviceService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.UUID; + +import static nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils.readByte; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils.readInt; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils.readShort; + +/* default */ class RealTimeActivityHandler { + private static final Logger LOG = LoggerFactory.getLogger(RealTimeActivityHandler.class); + + private final VivomoveHrSupport owner; + private final VivomoveHrActivitySample lastSample = new VivomoveHrActivitySample(); + + /* default */ RealTimeActivityHandler(VivomoveHrSupport owner) { + this.owner = owner; + } + + public boolean tryHandleChangedCharacteristic(UUID characteristicUUID, byte[] data) { + if (VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_HEART_RATE.equals(characteristicUUID)) { + processRealtimeHeartRate(data); + return true; + } + if (VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_STEPS.equals(characteristicUUID)) { + processRealtimeSteps(data); + return true; + } + if (VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_CALORIES.equals(characteristicUUID)) { + processRealtimeCalories(data); + return true; + } + if (VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_STAIRS.equals(characteristicUUID)) { + processRealtimeStairs(data); + return true; + } + if (VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_INTENSITY.equals(characteristicUUID)) { + processRealtimeIntensityMinutes(data); + return true; + } + if (VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_HEART_RATE_VARIATION.equals(characteristicUUID)) { + handleRealtimeHeartbeat(data); + return true; + } + + return false; + } + + private void processRealtimeHeartRate(byte[] data) { + int unknown1 = readByte(data, 0); + int heartRate = readByte(data, 1); + int unknown2 = readByte(data, 2); + int unknown3 = readShort(data, 3); + + lastSample.setHeartRate(heartRate); + processSample(); + + LOG.debug("Realtime HR {} ({}, {}, {})", heartRate, unknown1, unknown2, unknown3); + } + + private void processRealtimeSteps(byte[] data) { + int steps = readInt(data, 0); + int goal = readInt(data, 4); + + lastSample.setSteps(steps); + processSample(); + + LOG.debug("Realtime steps: {} steps (goal: {})", steps, goal); + } + + private void processRealtimeCalories(byte[] data) { + int calories = readInt(data, 0); + int unknown = readInt(data, 4); + + lastSample.setCaloriesBurnt(calories); + processSample(); + + LOG.debug("Realtime calories: {} cal burned (unknown: {})", calories, unknown); + } + + private void processRealtimeStairs(byte[] data) { + int floorsClimbed = readShort(data, 0); + int unknown = readShort(data, 2); + int floorGoal = readShort(data, 4); + + lastSample.setFloorsClimbed(floorsClimbed); + processSample(); + + LOG.debug("Realtime stairs: {} floors climbed (goal: {}, unknown: {})", floorsClimbed, floorGoal, unknown); + } + + private void processRealtimeIntensityMinutes(byte[] data) { + int weeklyLimit = readInt(data, 10); + + LOG.debug("Realtime intensity recorded; weekly limit: {}", weeklyLimit); + } + + private void handleRealtimeHeartbeat(byte[] data) { + int interval = readShort(data, 0); + int timer = readInt(data, 2); + + float heartRate = (60.0f * 1024.0f) / interval; + LOG.debug("Realtime heartbeat frequency {} at {}", heartRate, timer); + } + + private void processSample() { + if (lastSample.getCaloriesBurnt() == null || lastSample.getFloorsClimbed() == null || lastSample.getHeartRate() == 0 || lastSample.getSteps() == 0) { + LOG.debug("Skipping incomplete sample"); + return; + } + + try (final DBHandler dbHandler = GBApplication.acquireDB()) { + final DaoSession session = dbHandler.getDaoSession(); + + final GBDevice gbDevice = owner.getDevice(); + final Device device = DBHelper.getDevice(gbDevice, session); + final User user = DBHelper.getUser(session); + final int ts = (int) (System.currentTimeMillis() / 1000); + final VivomoveHrSampleProvider provider = new VivomoveHrSampleProvider(gbDevice, session); + final VivomoveHrActivitySample sample = createActivitySample(device, user, ts, provider); + + sample.setCaloriesBurnt(lastSample.getCaloriesBurnt()); + sample.setFloorsClimbed(lastSample.getFloorsClimbed()); + sample.setHeartRate(lastSample.getHeartRate()); + sample.setSteps(lastSample.getSteps()); + sample.setRawIntensity(ActivitySample.NOT_MEASURED); + sample.setRawKind(ActivityKind.TYPE_ACTIVITY); // to make it visible in the charts TODO: add a MANUAL kind for that? + + LOG.debug("Publishing sample"); + provider.addGBActivitySample(sample); + } catch (Exception e) { + LOG.error("Error saving real-time activity data", e); + } + + final Intent intent = new Intent(DeviceService.ACTION_REALTIME_SAMPLES) + .putExtra(DeviceService.EXTRA_REALTIME_SAMPLE, lastSample); + LocalBroadcastManager.getInstance(owner.getContext()).sendBroadcast(intent); + } + + public VivomoveHrActivitySample createActivitySample(Device device, User user, int timestampInSeconds, VivomoveHrSampleProvider provider) { + final VivomoveHrActivitySample sample = new VivomoveHrActivitySample(); + sample.setDevice(device); + sample.setUser(user); + sample.setTimestamp(timestampInSeconds); + sample.setProvider(provider); + + return sample; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/VivomoveHrCommunicator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/VivomoveHrCommunicator.java new file mode 100644 index 000000000..2f2ddf215 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/VivomoveHrCommunicator.java @@ -0,0 +1,91 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr; + +import android.bluetooth.BluetoothGattCharacteristic; +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.Arrays; + +public class VivomoveHrCommunicator { + private static final Logger LOG = LoggerFactory.getLogger(VivomoveHrCommunicator.class); + + private final AbstractBTLEDeviceSupport deviceSupport; + + private BluetoothGattCharacteristic characteristicMessageSender; + private BluetoothGattCharacteristic characteristicMessageReceiver; + private BluetoothGattCharacteristic characteristicHeartRate; + private BluetoothGattCharacteristic characteristicSteps; + private BluetoothGattCharacteristic characteristicCalories; + private BluetoothGattCharacteristic characteristicStairs; + private BluetoothGattCharacteristic characteristicHrVariation; + private BluetoothGattCharacteristic char2_9; + + public VivomoveHrCommunicator(AbstractBTLEDeviceSupport deviceSupport) { + this.deviceSupport = deviceSupport; + + this.characteristicMessageSender = deviceSupport.getCharacteristic(VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_GFDI_SEND); + this.characteristicMessageReceiver = deviceSupport.getCharacteristic(VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_GFDI_RECEIVE); + this.characteristicHeartRate = deviceSupport.getCharacteristic(VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_HEART_RATE); + this.characteristicSteps = deviceSupport.getCharacteristic(VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_STEPS); + this.characteristicCalories = deviceSupport.getCharacteristic(VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_CALORIES); + this.characteristicStairs = deviceSupport.getCharacteristic(VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_STAIRS); + this.characteristicHrVariation = deviceSupport.getCharacteristic(VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_HEART_RATE_VARIATION); + this.char2_9 = deviceSupport.getCharacteristic(VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_2_9); + } + + public void start(TransactionBuilder builder) { + builder.notify(characteristicMessageReceiver, true); +// builder.notify(characteristicHeartRate, true); +// builder.notify(characteristicSteps, true); +// builder.notify(characteristicCalories, true); +// builder.notify(characteristicStairs, true); + //builder.notify(char2_7, true); + // builder.notify(char2_9, true); + } + + public void sendMessage(byte[] messageBytes) { + try { + final TransactionBuilder builder = deviceSupport.performInitialized("sendMessage()"); + sendMessage(builder, messageBytes); + builder.queue(deviceSupport.getQueue()); + } catch (IOException e) { + LOG.error("Unable to send a message", e); + } + } + + private void sendMessage(TransactionBuilder builder, byte[] messageBytes) { + final byte[] packet = GfdiPacketParser.wrapMessageToPacket(messageBytes); + int remainingBytes = packet.length; + if (remainingBytes > VivomoveConstants.MAX_WRITE_SIZE) { + int position = 0; + while (remainingBytes > 0) { + final byte[] fragment = Arrays.copyOfRange(packet, position, position + Math.min(remainingBytes, VivomoveConstants.MAX_WRITE_SIZE)); + builder.write(characteristicMessageSender, fragment); + position += fragment.length; + remainingBytes -= fragment.length; + } + } else { + builder.write(characteristicMessageSender, packet); + } + } + + public void enableRealtimeSteps(boolean enable) { + try { + deviceSupport.performInitialized((enable ? "Enable" : "Disable") + " realtime steps").notify(characteristicSteps, enable).queue(deviceSupport.getQueue()); + } catch (IOException e) { + LOG.error("Unable to change realtime steps notification to: " + enable, e); + } + } + + public void enableRealtimeHeartRate(boolean enable) { + try { + deviceSupport.performInitialized((enable ? "Enable" : "Disable") + " realtime heartrate").notify(characteristicHeartRate, enable).queue(deviceSupport.getQueue()); + } catch (IOException ex) { + LOG.error("Unable to change realtime steps notification to: " + enable, ex); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/VivomoveHrSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/VivomoveHrSupport.java new file mode 100644 index 000000000..d8b22b4f7 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/VivomoveHrSupport.java @@ -0,0 +1,1059 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr; + +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCharacteristic; +import android.os.Build; +import android.widget.Toast; +import com.google.protobuf.InvalidProtocolBufferException; +import de.greenrobot.dao.query.Query; +import nodomain.freeyourgadget.gadgetbridge.BuildConfig; +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; +import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventFindPhone; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo; +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.GarminCapability; +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.Device; +import nodomain.freeyourgadget.gadgetbridge.entities.DownloadedFitFile; +import nodomain.freeyourgadget.gadgetbridge.entities.DownloadedFitFileDao; +import nodomain.freeyourgadget.gadgetbridge.entities.User; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.BatteryState; +import nodomain.freeyourgadget.gadgetbridge.model.MusicSpec; +import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec; +import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec; +import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ams.AmsEntity; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ams.AmsEntityAttribute; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs.AncsAttribute; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs.AncsAttributeRequest; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs.AncsEvent; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs.AncsGetNotificationAttributeCommand; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs.AncsGetNotificationAttributesResponse; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs.GncsDataSourceQueue; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.downloads.DirectoryData; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.downloads.DirectoryEntry; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.downloads.FileDownloadListener; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.downloads.FileDownloadQueue; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit.FitBool; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit.FitDbImporter; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit.FitMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit.FitMessageDefinitions; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit.FitParser; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit.FitWeatherConditions; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.AuthNegotiationMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.AuthNegotiationResponseMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.BatteryStatusMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.ConfigurationMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.CreateFileResponseMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.CurrentTimeRequestMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.CurrentTimeRequestResponseMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.DeviceInformationMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.DeviceInformationResponseMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.DirectoryFileFilterRequestMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.DirectoryFileFilterResponseMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.DownloadRequestResponseMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.FileTransferDataMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.FileTransferDataResponseMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.FindMyPhoneRequestMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.FitDataMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.FitDataResponseMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.FitDefinitionMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.FitDefinitionResponseMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.GenericResponseMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.GncsControlPointMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.GncsControlPointResponseMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.GncsDataSourceResponseMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.GncsNotificationSourceMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.MusicControlCapabilitiesMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.MusicControlCapabilitiesResponseMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.MusicControlEntityUpdateMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.NotificationServiceSubscriptionMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.NotificationServiceSubscriptionResponseMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.ProtobufRequestMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.ProtobufRequestResponseMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.ResponseMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.SetDeviceSettingsMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.SetDeviceSettingsResponseMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.SupportedFileTypesRequestMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.SupportedFileTypesResponseMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.SyncRequestMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.SystemEventMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.SystemEventResponseMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.UploadRequestResponseMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.WeatherRequestMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.WeatherRequestResponseMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.notifications.NotificationData; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.notifications.NotificationStorage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.protobuf.GdiCore; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.protobuf.GdiDeviceStatus; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.protobuf.GdiFindMyWatch; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.protobuf.GdiSmartProto; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.uploads.FileUploadQueue; +import nodomain.freeyourgadget.gadgetbridge.service.serial.GBDeviceProtocol; +import nodomain.freeyourgadget.gadgetbridge.util.GB; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Calendar; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TimeZone; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import static nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils.readShort; + +public class VivomoveHrSupport extends AbstractBTLEDeviceSupport implements FileDownloadListener { + private static final Logger LOG = LoggerFactory.getLogger(VivomoveHrSupport.class); + + // Should all FIT data files be fully stored into the database? (Currently, there is no user-friendly way to + // retrieve them, anyway.) + // TODO: Choose what to do with them. Either store them or remove the field from DB. + private static final boolean STORE_FIT_FILES = false; + + private final GfdiPacketParser gfdiPacketParser = new GfdiPacketParser(); + private Set capabilities; + + private int lastProtobufRequestId; + private int maxPacketSize; + + private final FitParser fitParser = new FitParser(FitMessageDefinitions.ALL_DEFINITIONS); + private final NotificationStorage notificationStorage = new NotificationStorage(); + private VivomoveHrCommunicator communicator; + private RealTimeActivityHandler realTimeActivityHandler; + private GncsDataSourceQueue gncsDataSourceQueue; + private FileDownloadQueue fileDownloadQueue; + private FileUploadQueue fileUploadQueue; + private FitDbImporter fitImporter; + private boolean notificationSubscription; + + public VivomoveHrSupport() { + super(LOG); + + addSupportedService(VivomoveConstants.UUID_SERVICE_GARMIN_1); + addSupportedService(VivomoveConstants.UUID_SERVICE_GARMIN_2); + } + + private int getNextProtobufRequestId() { + lastProtobufRequestId = (lastProtobufRequestId + 1) % 65536; + return lastProtobufRequestId; + } + + @Override + protected TransactionBuilder initializeDevice(TransactionBuilder builder) { + LOG.info("Initializing"); + + gbDevice.setState(GBDevice.State.INITIALIZING); + gbDevice.sendDeviceUpdateIntent(getContext()); + + communicator = new VivomoveHrCommunicator(this); + realTimeActivityHandler = new RealTimeActivityHandler(this); + + builder.setCallback(this); + communicator.start(builder); + fileDownloadQueue = new FileDownloadQueue(communicator, this); + fileUploadQueue = new FileUploadQueue(communicator); + + LOG.info("Initialization Done"); + + // OK, this is not perfect: we should not be INITIALIZED until “connected AND all the necessary initialization + // steps have been performed. At the very least, this means that basic information like device name, firmware + // version, hardware revision (as applicable) is available in the GBDevice”. But we cannot send any message + // until we are INITIALIZED. So what can we do… + gbDevice.setState(GBDevice.State.INITIALIZED); + gbDevice.sendDeviceUpdateIntent(getContext()); + + sendMessage(new AuthNegotiationMessage(AuthNegotiationMessage.LONG_TERM_KEY_AVAILABILITY_NONE, AuthNegotiationMessage.ENCRYPTION_ALGORITHM_NONE).packet); + + return builder; + } + + @Override + public boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { + final UUID characteristicUUID = characteristic.getUuid(); + if (super.onCharacteristicChanged(gatt, characteristic)) { + LOG.debug("Change of characteristic {} handled by parent", characteristicUUID); + return true; + } + + final byte[] data = characteristic.getValue(); + if (data.length == 0) { + LOG.debug("No data received on change of characteristic {}", characteristicUUID); + return true; + } + + if (VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_GFDI_RECEIVE.equals(characteristicUUID)) { + handleReceivedGfdiBytes(data); + } else if (realTimeActivityHandler.tryHandleChangedCharacteristic(characteristicUUID, data)) { + // handled by real-time activity handler + } else { + if (LOG.isDebugEnabled()) { + LOG.debug("Unknown characteristic {} changed: {}", characteristicUUID, GB.hexdump(data)); + } + } + + return true; + } + + private void sendMessage(byte[] messageBytes) { + communicator.sendMessage(messageBytes); + } + + private void handleReceivedGfdiBytes(byte[] data) { + gfdiPacketParser.receivedBytes(data); + LOG.debug("Received {} GFDI bytes", data.length); + byte[] packet; + while ((packet = gfdiPacketParser.retrievePacket()) != null) { + LOG.debug("Processing a {}B GFDI packet", packet.length); + processGfdiPacket(packet); + } + } + + private void processGfdiPacket(byte[] packet) { + final int size = readShort(packet, 0); + if (size != packet.length) { + LOG.error("Received GFDI packet with invalid length: {} vs {}", size, packet.length); + return; + } + final int crc = readShort(packet, packet.length - 2); + final int correctCrc = ChecksumCalculator.computeCrc(packet, 0, packet.length - 2); + if (crc != correctCrc) { + LOG.error("Received GFDI packet with invalid CRC: {} vs {}", crc, correctCrc); + return; + } + + final int messageType = readShort(packet, 2); + switch (messageType) { + case VivomoveConstants.MESSAGE_RESPONSE: + processResponseMessage(ResponseMessage.parsePacket(packet), packet); + break; + + case VivomoveConstants.MESSAGE_FILE_TRANSFER_DATA: + fileDownloadQueue.onFileTransferData(FileTransferDataMessage.parsePacket(packet)); + break; + + case VivomoveConstants.MESSAGE_DEVICE_INFORMATION: + processDeviceInformationMessage(DeviceInformationMessage.parsePacket(packet)); + break; + + case VivomoveConstants.MESSAGE_WEATHER_REQUEST: + processWeatherRequest(WeatherRequestMessage.parsePacket(packet)); + break; + + case VivomoveConstants.MESSAGE_MUSIC_CONTROL_CAPABILITIES: + processMusicControlCapabilities(MusicControlCapabilitiesMessage.parsePacket(packet)); + break; + + case VivomoveConstants.MESSAGE_CURRENT_TIME_REQUEST: + processCurrentTimeRequest(CurrentTimeRequestMessage.parsePacket(packet)); + break; + + case VivomoveConstants.MESSAGE_SYNC_REQUEST: + processSyncRequest(SyncRequestMessage.parsePacket(packet)); + break; + + case VivomoveConstants.MESSAGE_FIND_MY_PHONE: + processFindMyPhoneRequest(FindMyPhoneRequestMessage.parsePacket(packet)); + break; + + case VivomoveConstants.MESSAGE_CANCEL_FIND_MY_PHONE: + processCancelFindMyPhoneRequest(); + break; + + case VivomoveConstants.MESSAGE_NOTIFICATION_SERVICE_SUBSCRIPTION: + processNotificationServiceSubscription(NotificationServiceSubscriptionMessage.parsePacket(packet)); + break; + + case VivomoveConstants.MESSAGE_GNCS_CONTROL_POINT_REQUEST: + processGncsControlPointRequest(GncsControlPointMessage.parsePacket(packet)); + break; + + case VivomoveConstants.MESSAGE_CONFIGURATION: + processConfigurationMessage(ConfigurationMessage.parsePacket(packet)); + break; + + case VivomoveConstants.MESSAGE_PROTOBUF_RESPONSE: + processProtobufResponse(ProtobufRequestMessage.parsePacket(packet)); + break; + + default: + if (LOG.isInfoEnabled()) { + LOG.info("Unknown message type {}: {}", messageType, GB.hexdump(packet, 0, packet.length)); + } + break; + } + } + + private void processCancelFindMyPhoneRequest() { + LOG.info("Processing request to cancel find-my-phone"); + sendMessage(new GenericResponseMessage(VivomoveConstants.MESSAGE_FIND_MY_PHONE, 0).packet); + + final GBDeviceEventFindPhone findPhoneEvent = new GBDeviceEventFindPhone(); + findPhoneEvent.event = GBDeviceEventFindPhone.Event.STOP; + evaluateGBDeviceEvent(findPhoneEvent); + } + + private void processFindMyPhoneRequest(FindMyPhoneRequestMessage requestMessage) { + LOG.info("Processing find-my-phone request ({} s)", requestMessage.duration); + + sendMessage(new GenericResponseMessage(VivomoveConstants.MESSAGE_FIND_MY_PHONE, 0).packet); + + final GBDeviceEventFindPhone findPhoneEvent = new GBDeviceEventFindPhone(); + findPhoneEvent.event = GBDeviceEventFindPhone.Event.START; + evaluateGBDeviceEvent(findPhoneEvent); + } + + private void processGncsControlPointRequest(GncsControlPointMessage requestMessage) { + if (requestMessage == null) { + // TODO: Proper error handling with specific error code + sendMessage(new GncsControlPointResponseMessage(VivomoveConstants.STATUS_ACK, GncsControlPointResponseMessage.RESPONSE_ANCS_ERROR_OCCURRED, GncsControlPointResponseMessage.ANCS_ERROR_UNKNOWN_ANCS_COMMAND).packet); + return; + } + switch (requestMessage.command.command) { + case GET_NOTIFICATION_ATTRIBUTES: + final AncsGetNotificationAttributeCommand getNotificationAttributeCommand = (AncsGetNotificationAttributeCommand) requestMessage.command; + LOG.info("Processing ANCS request to get attributes of notification #{}", getNotificationAttributeCommand.notificationUID); + sendMessage(new GncsControlPointResponseMessage(VivomoveConstants.STATUS_ACK, GncsControlPointResponseMessage.RESPONSE_SUCCESSFUL, GncsControlPointResponseMessage.ANCS_ERROR_NO_ERROR).packet); + final NotificationData notificationData = notificationStorage.retrieveNotification(getNotificationAttributeCommand.notificationUID); + if (notificationData == null) { + LOG.warn("Notification #{} not registered", getNotificationAttributeCommand.notificationUID); + } + final Map attributes = new LinkedHashMap<>(); + for (final AncsAttributeRequest attributeRequest : getNotificationAttributeCommand.attributes) { + final AncsAttribute attribute = attributeRequest.attribute; + final String attributeValue = notificationData == null ? null : notificationData.getAttribute(attributeRequest.attribute); + final String valueShortened = attributeRequest.maxLength > 0 && attributeValue != null && attributeValue.length() > attributeRequest.maxLength ? attributeValue.substring(0, attributeRequest.maxLength) : attributeValue; + LOG.debug("Requested ANCS attribute {}: '{}'", attribute, valueShortened); + attributes.put(attribute, valueShortened == null ? "" : valueShortened); + } + gncsDataSourceQueue.addToQueue(new AncsGetNotificationAttributesResponse(getNotificationAttributeCommand.notificationUID, attributes).packet); + break; + + default: + LOG.error("Unknown GNCS control point command {}", requestMessage.command.command); + sendMessage(new GncsControlPointResponseMessage(VivomoveConstants.STATUS_ACK, GncsControlPointResponseMessage.RESPONSE_ANCS_ERROR_OCCURRED, GncsControlPointResponseMessage.ANCS_ERROR_UNKNOWN_ANCS_COMMAND).packet); + break; + } + } + + private void processNotificationServiceSubscription(NotificationServiceSubscriptionMessage requestMessage) { + LOG.info("Processing notification service subscription request message: intent={}, flags={}", requestMessage.intentIndicator, requestMessage.featureFlags); + notificationSubscription = requestMessage.intentIndicator == NotificationServiceSubscriptionMessage.INTENT_SUBSCRIBE; + sendMessage(new NotificationServiceSubscriptionResponseMessage(0, 0, requestMessage.intentIndicator, requestMessage.featureFlags).packet); + } + + private void processSyncRequest(SyncRequestMessage requestMessage) { + if (LOG.isInfoEnabled()) { + final StringBuilder requestedTypes = new StringBuilder(); + for (GarminMessageType type : requestMessage.fileTypes) { + if (requestedTypes.length() > 0) { + requestedTypes.append(", "); + } + requestedTypes.append(type); + } + LOG.info("Processing sync request message: option={}, types: {}", requestMessage.option, requestedTypes); + } + sendMessage(new GenericResponseMessage(VivomoveConstants.MESSAGE_SYNC_REQUEST, 0).packet); + if (requestMessage.option != SyncRequestMessage.OPTION_INVISIBLE) { + doSync(); + } + } + + private void processProtobufResponse(ProtobufRequestMessage requestMessage) { + LOG.info("Received protobuf response #{}, {}B@{}/{}: {}", requestMessage.requestId, requestMessage.protobufDataLength, requestMessage.dataOffset, requestMessage.totalProtobufLength, GB.hexdump(requestMessage.messageBytes, 0, requestMessage.messageBytes.length)); + sendMessage(new GenericResponseMessage(VivomoveConstants.MESSAGE_PROTOBUF_RESPONSE, 0).packet); + final GdiSmartProto.Smart smart; + try { + smart = GdiSmartProto.Smart.parseFrom(requestMessage.messageBytes); + } catch (InvalidProtocolBufferException e) { + LOG.error("Failed to parse protobuf message ({}): {}", e.getLocalizedMessage(), GB.hexdump(requestMessage.messageBytes, 0, requestMessage.messageBytes.length)); + return; + } + boolean processed = false; + if (smart.hasDeviceStatusService()) { + processProtobufDeviceStatusResponse(smart.getDeviceStatusService()); + processed = true; + } + if (smart.hasFindMyWatchService()) { + processProtobufFindMyWatchResponse(smart.getFindMyWatchService()); + processed = true; + } + if (smart.hasCoreService()) { + processProtobufCoreResponse(smart.getCoreService()); + processed = true; + } + if (!processed) { + LOG.warn("Unknown protobuf response: {}", smart); + } + } + + 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; + handleGBDeviceEvent(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 void processProtobufFindMyWatchResponse(GdiFindMyWatch.FindMyWatchService findMyWatchService) { + if (findMyWatchService.hasCancelRequest()) { + LOG.info("Watch search cancelled, watch found"); + GBApplication.deviceService().onFindDevice(false); + return; + } + if (findMyWatchService.hasCancelResponse() || findMyWatchService.hasFindResponse()) { + LOG.debug("Received findMyWatch response"); + return; + } + LOG.warn("Unknown FindMyWatchService response: {}", findMyWatchService); + } + + private void processMusicControlCapabilities(MusicControlCapabilitiesMessage capabilitiesMessage) { + LOG.info("Processing music control capabilities request caps={}", capabilitiesMessage.supportedCapabilities); + sendMessage(new MusicControlCapabilitiesResponseMessage(0, GarminMusicControlCommand.values()).packet); + } + + private void processWeatherRequest(WeatherRequestMessage requestMessage) { + LOG.info("Processing weather request fmt={}, {} hrs, {}/{}", requestMessage.format, requestMessage.hoursOfForecast, requestMessage.latitude, requestMessage.longitude); + sendMessage(new WeatherRequestResponseMessage(0, 0, 1, 300).packet); + } + + private void processCurrentTimeRequest(CurrentTimeRequestMessage requestMessage) { + 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={}", requestMessage.referenceID, garminTimestamp, dstOffset, timeZoneOffset); + sendMessage(new CurrentTimeRequestResponseMessage(0, requestMessage.referenceID, garminTimestamp, timeZoneOffset, dstOffset).packet); + } + + private void processResponseMessage(ResponseMessage responseMessage, byte[] packet) { + switch (responseMessage.requestID) { + case VivomoveConstants.MESSAGE_DIRECTORY_FILE_FILTER_REQUEST: + processDirectoryFileFilterResponse(DirectoryFileFilterResponseMessage.parsePacket(packet)); + break; + case VivomoveConstants.MESSAGE_DOWNLOAD_REQUEST: + fileDownloadQueue.onDownloadRequestResponse(DownloadRequestResponseMessage.parsePacket(packet)); + break; + case VivomoveConstants.MESSAGE_UPLOAD_REQUEST: + fileUploadQueue.onUploadRequestResponse(UploadRequestResponseMessage.parsePacket(packet)); + break; + case VivomoveConstants.MESSAGE_FILE_TRANSFER_DATA: + fileUploadQueue.onFileTransferResponse(FileTransferDataResponseMessage.parsePacket(packet)); + break; + case VivomoveConstants.MESSAGE_CREATE_FILE_REQUEST: + fileUploadQueue.onCreateFileRequestResponse(CreateFileResponseMessage.parsePacket(packet)); + break; + case VivomoveConstants.MESSAGE_FIT_DEFINITION: + processFitDefinitionResponse(FitDefinitionResponseMessage.parsePacket(packet)); + break; + case VivomoveConstants.MESSAGE_FIT_DATA: + processFitDataResponse(FitDataResponseMessage.parsePacket(packet)); + break; + case VivomoveConstants.MESSAGE_PROTOBUF_REQUEST: + processProtobufRequestResponse(ProtobufRequestResponseMessage.parsePacket(packet)); + break; + case VivomoveConstants.MESSAGE_DEVICE_SETTINGS: + processDeviceSettingsResponse(SetDeviceSettingsResponseMessage.parsePacket(packet)); + break; + case VivomoveConstants.MESSAGE_SYSTEM_EVENT: + processSystemEventResponse(SystemEventResponseMessage.parsePacket(packet)); + break; + case VivomoveConstants.MESSAGE_SUPPORTED_FILE_TYPES_REQUEST: + processSupportedFileTypesResponse(SupportedFileTypesResponseMessage.parsePacket(packet)); + break; + case VivomoveConstants.MESSAGE_GNCS_DATA_SOURCE: + gncsDataSourceQueue.responseReceived(GncsDataSourceResponseMessage.parsePacket(packet)); + break; + case VivomoveConstants.MESSAGE_AUTH_NEGOTIATION: + processAuthNegotiationRequestResponse(AuthNegotiationResponseMessage.parsePacket(packet)); + break; + default: + LOG.info("Received response to message {}: {}", responseMessage.requestID, responseMessage.getStatusStr()); + break; + } + } + + private void processDirectoryFileFilterResponse(DirectoryFileFilterResponseMessage responseMessage) { + if (responseMessage.status == VivomoveConstants.STATUS_ACK && responseMessage.response == DirectoryFileFilterResponseMessage.RESPONSE_DIRECTORY_FILTER_APPLIED) { + LOG.info("Received response to directory file filter request: {}/{}, requesting download of directory data", responseMessage.status, responseMessage.response); + fileDownloadQueue.addToDownloadQueue(0, 0); + } else { + LOG.error("Directory file filter request failed: {}/{}", responseMessage.status, responseMessage.response); + } + } + + private void processSupportedFileTypesResponse(SupportedFileTypesResponseMessage responseMessage) { + final StringBuilder supportedTypes = new StringBuilder(); + for (SupportedFileTypesResponseMessage.FileTypeInfo type : responseMessage.fileTypes) { + if (supportedTypes.length() > 0) { + supportedTypes.append(", "); + } + supportedTypes.append(String.format(Locale.ROOT, "%d/%d: %s", type.fileDataType, type.fileSubType, type.garminDeviceFileType)); + } + LOG.info("Received the list of supported file types (status={}): {}", responseMessage.status, supportedTypes); + } + + private void processDeviceSettingsResponse(SetDeviceSettingsResponseMessage responseMessage) { + LOG.info("Received response to device settings message: status={}, response={}", responseMessage.status, responseMessage.response); + } + + private void processAuthNegotiationRequestResponse(AuthNegotiationResponseMessage responseMessage) { + LOG.info("Received response to auth negotiation message: status={}, response={}, LTK={}, algorithms={}", responseMessage.status, responseMessage.response, responseMessage.longTermKeyAvailability, responseMessage.supportedEncryptionAlgorithms); + } + + private void processSystemEventResponse(SystemEventResponseMessage responseMessage) { + LOG.info("Received response to system event message: status={}, response={}", responseMessage.status, responseMessage.response); + } + + private void processFitDefinitionResponse(FitDefinitionResponseMessage responseMessage) { + LOG.info("Received response to FIT definition message: status={}, FIT response={}", responseMessage.status, responseMessage.fitResponse); + } + + private void processFitDataResponse(FitDataResponseMessage responseMessage) { + LOG.info("Received response to FIT data message: status={}, FIT response={}", responseMessage.status, responseMessage.fitResponse); + } + + private void processProtobufRequestResponse(ProtobufRequestResponseMessage responseMessage) { + LOG.info("Received response to protobuf message #{}@{}: status={}, error={}", responseMessage.requestId, responseMessage.dataOffset, responseMessage.protobufStatus, responseMessage.error); + } + + private void processDeviceInformationMessage(DeviceInformationMessage deviceInformationMessage) { + LOG.info("Received device information: protocol {}, product {}, unit {}, SW {}, max packet {}, BT name {}, device name {}, device model {}", deviceInformationMessage.protocolVersion, deviceInformationMessage.productNumber, deviceInformationMessage.unitNumber, deviceInformationMessage.getSoftwareVersionStr(), deviceInformationMessage.maxPacketSize, deviceInformationMessage.bluetoothFriendlyName, deviceInformationMessage.deviceName, deviceInformationMessage.deviceModel); + + this.maxPacketSize = deviceInformationMessage.maxPacketSize; + this.gncsDataSourceQueue = new GncsDataSourceQueue(communicator, maxPacketSize); + + final GBDeviceEventVersionInfo deviceEventVersionInfo = new GBDeviceEventVersionInfo(); + deviceEventVersionInfo.fwVersion = deviceInformationMessage.getSoftwareVersionStr(); + handleGBDeviceEvent(deviceEventVersionInfo); + + gbDevice.setState(GBDevice.State.INITIALIZED); + gbDevice.sendDeviceUpdateIntent(getContext()); + + // prepare and send response + final boolean protocolVersionSupported = deviceInformationMessage.protocolVersion / 100 == 1; + if (!protocolVersionSupported) { + LOG.error("Unsupported protocol version {}", deviceInformationMessage.protocolVersion); + } + final int protocolFlags = protocolVersionSupported ? 1 : 0; + final DeviceInformationResponseMessage deviceInformationResponseMessage = new DeviceInformationResponseMessage(VivomoveConstants.STATUS_ACK, 112, -1, VivomoveConstants.GADGETBRIDGE_UNIT_NUMBER, BuildConfig.VERSION_CODE, 16384, getBluetoothAdapter().getName(), Build.MANUFACTURER, Build.DEVICE, protocolFlags); + + sendMessage(deviceInformationResponseMessage.packet); + } + + private void processConfigurationMessage(ConfigurationMessage configurationMessage) { + this.capabilities = GarminCapability.setFromBinary(configurationMessage.configurationPayload); + + if (LOG.isInfoEnabled()) { + LOG.info("Received configuration message; capabilities: {}", GarminCapability.setToString(capabilities)); + } + + // prepare and send response + sendMessage(new GenericResponseMessage(VivomoveConstants.MESSAGE_CONFIGURATION, VivomoveConstants.STATUS_ACK).packet); + + // and report our own configuration/capabilities + final byte[] ourCapabilityFlags = GarminCapability.setToBinary(VivomoveConstants.OUR_CAPABILITIES); + sendMessage(new ConfigurationMessage(ourCapabilityFlags).packet); + + // initialize current time and settings + sendCurrentTime(); + sendSettings(); + + // and everything is ready now + sendSyncReady(); + requestBatteryStatusUpdate(); + sendFitDefinitions(); + sendFitConnectivityMessage(); + requestSupportedFileTypes(); + } + + private void sendProtobufRequest(byte[] protobufMessage) { + final int requestId = getNextProtobufRequestId(); + if (LOG.isDebugEnabled()) { + LOG.debug("Sending {}B protobuf request #{}: {}", protobufMessage.length, requestId, GB.hexdump(protobufMessage, 0, protobufMessage.length)); + } + sendMessage(new ProtobufRequestMessage(requestId, 0, protobufMessage.length, protobufMessage.length, protobufMessage).packet); + } + + private void requestBatteryStatusUpdate() { + sendProtobufRequest( + GdiSmartProto.Smart.newBuilder() + .setDeviceStatusService( + GdiDeviceStatus.DeviceStatusService.newBuilder() + .setRemoteDeviceBatteryStatusRequest( + GdiDeviceStatus.DeviceStatusService.RemoteDeviceBatteryStatusRequest.newBuilder() + ) + ) + .build() + .toByteArray()); + } + + private void requestActivityStatus() { + sendProtobufRequest( + GdiSmartProto.Smart.newBuilder() + .setDeviceStatusService( + GdiDeviceStatus.DeviceStatusService.newBuilder() + .setActivityStatusRequest( + GdiDeviceStatus.DeviceStatusService.ActivityStatusRequest.newBuilder() + ) + ) + .build() + .toByteArray()); + } + + private void sendRequestSync() { + sendProtobufRequest( + GdiSmartProto.Smart.newBuilder() + .setCoreService( + GdiCore.CoreService.newBuilder() + .setSyncRequest( + GdiCore.CoreService.SyncRequest.newBuilder() + ) + ) + .build() + .toByteArray()); + } + + private void requestSupportedFileTypes() { + LOG.info("Requesting list of supported file types"); + sendMessage(new SupportedFileTypesRequestMessage().packet); + } + + private void sendSyncReady() { + sendMessage(new SystemEventMessage(GarminSystemEventType.SYNC_READY, 0).packet); + } + + private void sendCurrentTime() { + final Map settings = new LinkedHashMap<>(3); + + 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); + + settings.put(GarminDeviceSetting.CURRENT_TIME, garminTimestamp); + settings.put(GarminDeviceSetting.DAYLIGHT_SAVINGS_TIME_OFFSET, dstOffset); + settings.put(GarminDeviceSetting.TIME_ZONE_OFFSET, timeZoneOffset); + // TODO: NEXT_DAYLIGHT_SAVINGS_START, NEXT_DAYLIGHT_SAVINGS_END + LOG.info("Setting time to {}, dstOffset={}, tzOffset={} (DST={})", garminTimestamp, dstOffset, timeZoneOffset, timeZone.inDaylightTime(new Date(now)) ? 1 : 0); + sendMessage(new SetDeviceSettingsMessage(settings).packet); + } + + private void sendSettings() { + final Map settings = new LinkedHashMap<>(3); + + settings.put(GarminDeviceSetting.WEATHER_CONDITIONS_ENABLED, true); + settings.put(GarminDeviceSetting.WEATHER_ALERTS_ENABLED, true); + settings.put(GarminDeviceSetting.AUTO_UPLOAD_ENABLED, true); + LOG.info("Sending settings"); + sendMessage(new SetDeviceSettingsMessage(settings).packet); + } + + private void sendFitDefinitions() { + sendMessage(new FitDefinitionMessage( + FitMessageDefinitions.DEFINITION_CONNECTIVITY, + FitMessageDefinitions.DEFINITION_WEATHER_CONDITIONS, + FitMessageDefinitions.DEFINITION_WEATHER_ALERT, + FitMessageDefinitions.DEFINITION_DEVICE_SETTINGS + ).packet); + } + + private void sendFitConnectivityMessage() { + final FitMessage connectivityMessage = new FitMessage(FitMessageDefinitions.DEFINITION_CONNECTIVITY); + connectivityMessage.setField(0, FitBool.TRUE); + connectivityMessage.setField(1, FitBool.TRUE); + connectivityMessage.setField(2, FitBool.INVALID); + connectivityMessage.setField(4, FitBool.TRUE); + connectivityMessage.setField(5, FitBool.TRUE); + connectivityMessage.setField(6, FitBool.TRUE); + connectivityMessage.setField(7, FitBool.TRUE); + connectivityMessage.setField(8, FitBool.TRUE); + connectivityMessage.setField(9, FitBool.TRUE); + connectivityMessage.setField(10, FitBool.TRUE); + connectivityMessage.setField(13, FitBool.TRUE); + sendMessage(new FitDataMessage(connectivityMessage).packet); + } + + private void sendWeatherConditions(WeatherSpec weather) { + final FitMessage weatherConditionsMessage = new FitMessage(FitMessageDefinitions.DEFINITION_WEATHER_CONDITIONS); + weatherConditionsMessage.setField(253, GarminTimeUtils.unixTimeToGarminTimestamp(weather.timestamp)); + weatherConditionsMessage.setField(0, 0); // 0 = current, 2 = hourly_forecast, 3 = daily_forecast + weatherConditionsMessage.setField(1, weather.currentTemp); + weatherConditionsMessage.setField(2, FitWeatherConditions.openWeatherCodeToFitWeatherStatus(weather.currentConditionCode)); + weatherConditionsMessage.setField(3, weather.windDirection); + weatherConditionsMessage.setField(4, Math.round(weather.windSpeed * 1000.0 / 3.6)); + weatherConditionsMessage.setField(7, weather.currentHumidity); + weatherConditionsMessage.setField(8, weather.location); + final Calendar timestamp = Calendar.getInstance(); + timestamp.setTimeInMillis(weather.timestamp * 1000L); + weatherConditionsMessage.setField(12, timestamp.get(Calendar.DAY_OF_WEEK)); + weatherConditionsMessage.setField(13, weather.todayMaxTemp); + weatherConditionsMessage.setField(14, weather.todayMinTemp); + + sendMessage(new FitDataMessage(weatherConditionsMessage).packet); + } + + /* + private void sendWeatherAlert() { + final FitMessage weatherConditionsMessage = new FitMessage(FitMessageDefinitions.DEFINITION_WEATHER_ALERT); + weatherConditionsMessage.setField(253, GarminTimeUtils.javaMillisToGarminTimestamp(System.currentTimeMillis())); + weatherConditionsMessage.setField(0, "TESTRPT"); + final Calendar issue = Calendar.getInstance(); + issue.set(2019, 8, 27, 0, 0, 0); + final Calendar expiry = Calendar.getInstance(); + issue.set(2019, 8, 29, 0, 0, 0); + weatherConditionsMessage.setField(1, GarminTimeUtils.javaMillisToGarminTimestamp(issue.getTimeInMillis())); + weatherConditionsMessage.setField(2, GarminTimeUtils.javaMillisToGarminTimestamp(expiry.getTimeInMillis())); + weatherConditionsMessage.setField(3, FitWeatherConditions.ALERT_SEVERITY_ADVISORY); + weatherConditionsMessage.setField(4, FitWeatherConditions.ALERT_TYPE_SEVERE_THUNDERSTORM); + + sendMessage(new FitDataMessage(weatherConditionsMessage).packet); + } + */ + + private void sendNotification(AncsEvent event, NotificationData notification) { + if (event == AncsEvent.NOTIFICATION_ADDED) { + notificationStorage.registerNewNotification(notification); + } else { + notificationStorage.deleteNotification(notification.spec.getId()); + } + sendMessage(new GncsNotificationSourceMessage(event, notification.flags, notification.category, notificationStorage.getCountInCategory(notification.category), notification.spec.getId()).packet); + } + + private void listFiles(int filterType) { + LOG.info("Requesting file list (filter={})", filterType); + sendMessage(new DirectoryFileFilterRequestMessage(filterType).packet); + } + + private void downloadFile(int fileIndex) { + LOG.info("Requesting download of file {}", fileIndex); + fileDownloadQueue.addToDownloadQueue(fileIndex, 0); + } + + private void downloadGarminDeviceXml() { + LOG.info("Requesting Garmin device XML download"); + fileDownloadQueue.addToDownloadQueue(VivomoveConstants.GARMIN_DEVICE_XML_FILE_INDEX, 0); + } + + private void sendBatteryStatus(int batteryLevel) { + LOG.info("Sending battery status"); + sendMessage(new BatteryStatusMessage(batteryLevel).packet); + } + + private void doSync() { + LOG.info("Starting sync"); + fitImporter = new FitDbImporter(getDevice()); + // sendMessage(new SystemEventMessage(GarminSystemEventType.PAIR_START, 0).packet); + listFiles(DirectoryFileFilterRequestMessage.FILTER_NO_FILTER); + // TODO: Localization + GB.updateTransferNotification(null, "Downloading list of files", true, 0, getContext()); + } + + @Override + public boolean useAutoConnect() { + return true; + } + + @Override + public void onNotification(NotificationSpec notificationSpec) { + if (notificationSubscription) { + sendNotification(AncsEvent.NOTIFICATION_ADDED, new NotificationData(notificationSpec)); + } else { + LOG.debug("No notification subscription is active, ignoring notification"); + } + } + + @Override + public void onDeleteNotification(int id) { + final NotificationData notificationData = notificationStorage.retrieveNotification(id); + if (notificationData != null) { + sendNotification(AncsEvent.NOTIFICATION_REMOVED, notificationData); + } + } + + @Override + public void onSetTime() { + sendCurrentTime(); + } + + @Override + public void onSetMusicInfo(MusicSpec musicSpec) { + sendMessage(new MusicControlEntityUpdateMessage( + new AmsEntityAttribute(AmsEntity.TRACK, AmsEntityAttribute.TRACK_ATTRIBUTE_ARTIST, 0, musicSpec.artist), + new AmsEntityAttribute(AmsEntity.TRACK, AmsEntityAttribute.TRACK_ATTRIBUTE_ALBUM, 0, musicSpec.album), + new AmsEntityAttribute(AmsEntity.TRACK, AmsEntityAttribute.TRACK_ATTRIBUTE_TITLE, 0, musicSpec.track), + new AmsEntityAttribute(AmsEntity.TRACK, AmsEntityAttribute.TRACK_ATTRIBUTE_DURATION, 0, String.valueOf(musicSpec.duration)) + ).packet); + } + + @Override + public void onEnableRealtimeSteps(boolean enable) { + communicator.enableRealtimeSteps(enable); + } + + @Override + public void onFetchRecordedData(int dataTypes) { + doSync(); + } + + @Override + public void onReset(int flags) { + switch (flags) { + case GBDeviceProtocol.RESET_FLAGS_FACTORY_RESET: + LOG.warn("Requesting factory reset"); + sendMessage(new SystemEventMessage(GarminSystemEventType.FACTORY_RESET, 1).packet); + break; + + default: + GB.toast(getContext(), "This kind of reset not supported for this device", Toast.LENGTH_LONG, GB.ERROR); + break; + } + } + + @Override + public void onEnableRealtimeHeartRateMeasurement(boolean enable) { + communicator.enableRealtimeHeartRate(enable); + } + + @Override + public void onFindDevice(boolean start) { + if (start) { + sendProtobufRequest( + GdiSmartProto.Smart.newBuilder() + .setFindMyWatchService( + GdiFindMyWatch.FindMyWatchService.newBuilder() + .setFindRequest( + GdiFindMyWatch.FindMyWatchService.FindMyWatchRequest.newBuilder() + .setTimeout(60) + ) + ) + .build() + .toByteArray()); + } else { + sendProtobufRequest( + GdiSmartProto.Smart.newBuilder() + .setFindMyWatchService( + GdiFindMyWatch.FindMyWatchService.newBuilder() + .setCancelRequest( + GdiFindMyWatch.FindMyWatchService.FindMyWatchCancelRequest.newBuilder() + ) + ) + .build() + .toByteArray()); + } + } + + private void updateDeviceSettings() { + final FitMessage deviceSettingsMessage = new FitMessage(FitMessageDefinitions.DEFINITION_DEVICE_SETTINGS); + deviceSettingsMessage.setField("bluetooth_connection_alerts_enabled", 0); + deviceSettingsMessage.setField("auto_lock_enabled", 0); + deviceSettingsMessage.setField("activity_tracker_enabled", 1); + deviceSettingsMessage.setField("alarm_time", 0); + deviceSettingsMessage.setField("ble_auto_upload_enabled", 1); + deviceSettingsMessage.setField("autosync_min_steps", 1000); + deviceSettingsMessage.setField("vibration_intensity", 2); + deviceSettingsMessage.setField("screen_timeout", 0); + deviceSettingsMessage.setField("mounting_side", 1); + deviceSettingsMessage.setField("phone_notification_activity_filter", 0); + deviceSettingsMessage.setField("auto_goal_enabled", 1); + deviceSettingsMessage.setField("autosync_min_time", 60); + deviceSettingsMessage.setField("glance_mode_layout", 0); + deviceSettingsMessage.setField("time_offset", 7200); + deviceSettingsMessage.setField("phone_notification_default_filter", 0); + deviceSettingsMessage.setField("alarm_mode", -1); + deviceSettingsMessage.setField("backlight_timeout", 5); + deviceSettingsMessage.setField("sedentary_hr_alert_threshold", 100); + deviceSettingsMessage.setField("backlight_brightness", 0); + deviceSettingsMessage.setField("time_zone", 254); + deviceSettingsMessage.setField("sedentary_hr_alert_state", 0); + deviceSettingsMessage.setField("auto_activity_start_enabled", 0); + deviceSettingsMessage.setField("alarm_days", 0); + deviceSettingsMessage.setField("default_page", 1); + deviceSettingsMessage.setField("message_tones_enabled", 2); + deviceSettingsMessage.setField("key_tones_enabled", 2); + deviceSettingsMessage.setField("date_mode", 0); + deviceSettingsMessage.setField("backlight_gesture", 1); + deviceSettingsMessage.setField("backlight_mode", 3); + deviceSettingsMessage.setField("move_alert_enabled", 1); + deviceSettingsMessage.setField("sleep_do_not_disturb_enabled", 0); + deviceSettingsMessage.setField("display_orientation", 2); + deviceSettingsMessage.setField("time_mode", 1); + deviceSettingsMessage.setField("pages_enabled", 127); + deviceSettingsMessage.setField("smart_notification_display_orientation", 0); + deviceSettingsMessage.setField("display_steps_goal_enabled", 1); + sendMessage(new FitDataMessage(deviceSettingsMessage).packet); + } + + private boolean foreground; + + @Override + public void onSendWeather(WeatherSpec weatherSpec) { + sendWeatherConditions(weatherSpec); + } + + private final Map filesToDownload = new ConcurrentHashMap<>(); + private long totalDownloadSize; + private long lastTransferNotificationTimestamp; + + private DownloadedFitFile findDownloadedFitFile(DaoSession session, Device device, User user, int fileNumber, int fileDataType, int fileSubType) { + final DownloadedFitFileDao fileDao = session.getDownloadedFitFileDao(); + final Query query = fileDao.queryBuilder() + .where( + DownloadedFitFileDao.Properties.DeviceId.eq(device.getId()), + DownloadedFitFileDao.Properties.UserId.eq(user.getId()), + DownloadedFitFileDao.Properties.FileNumber.eq(fileNumber), + DownloadedFitFileDao.Properties.FileDataType.eq(fileDataType), + DownloadedFitFileDao.Properties.FileSubType.eq(fileSubType) + ) + .build(); + + final List files = query.list(); + return files.size() > 0 ? files.get(0) : null; + } + + @Override + public void onDirectoryDownloaded(DirectoryData directoryData) { + if (filesToDownload.size() != 0) { + throw new IllegalStateException("File download already in progress!"); + } + + long totalSize = 0; + try { + try (final DBHandler dbHandler = GBApplication.acquireDB()) { + final DaoSession session = dbHandler.getDaoSession(); + final GBDevice gbDevice = getDevice(); + final Device device = DBHelper.getDevice(gbDevice, session); + final User user = DBHelper.getUser(session); + + for (final DirectoryEntry entry : directoryData.entries) { + LOG.info("File #{}: type {}/{} #{}, {}B, flags {}/{}, timestamp {}", entry.fileIndex, entry.fileDataType, entry.fileSubType, entry.fileNumber, entry.fileSize, entry.specificFlags, entry.fileFlags, entry.fileDate); + if (entry.fileIndex == 0) { + // ? + LOG.warn("File #0 reported?"); + continue; + } + + final long timestamp = entry.fileDate.getTime(); + final DownloadedFitFile alreadyDownloadedFile = findDownloadedFitFile(session, device, user, entry.fileNumber, entry.fileDataType, entry.fileSubType); + if (alreadyDownloadedFile == null) { + LOG.debug("File not yet downloaded"); + } else { + if (alreadyDownloadedFile.getFileTimestamp() == timestamp && alreadyDownloadedFile.getFileSize() == entry.fileSize) { + LOG.debug("File already downloaded, skipping"); + continue; + } else { + LOG.info("File #{} modified after previous download, removing previous version and re-downloading", entry.fileIndex); + alreadyDownloadedFile.delete(); + } + } + + filesToDownload.put(entry.fileIndex, entry); + fileDownloadQueue.addToDownloadQueue(entry.fileIndex, entry.fileSize); + totalSize += entry.fileSize; + } + } + } catch (Exception e) { + LOG.error("Error storing data to DB", e); + } + + totalDownloadSize = totalSize; + } + + @Override + public void onFileDownloadComplete(int fileIndex, byte[] data) { + LOG.info("Downloaded file {}: {} bytes", fileIndex, data.length); + final DirectoryEntry downloadedDirectoryEntry = filesToDownload.get(fileIndex); + if (downloadedDirectoryEntry == null) { + LOG.warn("Unexpected file {} downloaded", fileIndex); + } else { + try (final DBHandler dbHandler = GBApplication.acquireDB()) { + final DaoSession session = dbHandler.getDaoSession(); + + final GBDevice gbDevice = getDevice(); + final Device device = DBHelper.getDevice(gbDevice, session); + final User user = DBHelper.getUser(session); + final int ts = (int) (System.currentTimeMillis() / 1000); + + final DownloadedFitFile downloadedFitFile = new DownloadedFitFile(null, ts, device.getId(), user.getId(), downloadedDirectoryEntry.fileNumber, downloadedDirectoryEntry.fileDataType, downloadedDirectoryEntry.fileSubType, downloadedDirectoryEntry.fileDate.getTime(), downloadedDirectoryEntry.specificFlags, downloadedDirectoryEntry.fileSize, STORE_FIT_FILES ? data : null); + session.getDownloadedFitFileDao().insert(downloadedFitFile); + } catch (Exception e) { + LOG.error("Error saving downloaded file to database", e); + } + } + + if (fileIndex <= 0x8000) { + fitImporter.processFitFile(fitParser.parseFitFile(data)); + } else { + LOG.debug("Not importing file {} as FIT", fileIndex); + } + } + + @Override + public void onFileDownloadError(int fileIndex) { + LOG.error("Failed to download file {}", fileIndex); + } + + @Override + public void onDownloadProgress(long remainingBytes) { + LOG.debug("{}B/{} remaining to download", remainingBytes, totalDownloadSize); + if (remainingBytes == 0) { + GB.updateTransferNotification(null, null, false, 100, getContext()); + } else if (totalDownloadSize > 0) { + final long now = System.currentTimeMillis(); + if (now - lastTransferNotificationTimestamp < 1000) { + // do not issue updates too often + return; + } + lastTransferNotificationTimestamp = now; + // TODO: Localization + GB.updateTransferNotification(null, "Downloading data", true, Math.round(100.0f * (totalDownloadSize - remainingBytes) / totalDownloadSize), getContext()); + } + } + + @Override + public void onAllDownloadsCompleted() { + LOG.info("All downloads completed"); + GB.updateTransferNotification(null, null, false, 100, getContext()); + sendMessage(new SystemEventMessage(GarminSystemEventType.SYNC_COMPLETE, 0).packet); + if (fitImporter != null) { + fitImporter.processData(); + GB.signalActivityDataFinish(); + } + fitImporter = null; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ams/AmsEntity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ams/AmsEntity.java new file mode 100644 index 000000000..6d5ed25ac --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ams/AmsEntity.java @@ -0,0 +1,7 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ams; + +public enum AmsEntity { + PLAYER, + QUEUE, + TRACK +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ams/AmsEntityAttribute.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ams/AmsEntityAttribute.java new file mode 100644 index 000000000..1ed0afc19 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ams/AmsEntityAttribute.java @@ -0,0 +1,42 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ams; + +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.MessageWriter; + +import java.nio.charset.StandardCharsets; + +public class AmsEntityAttribute { + public static final int PLAYER_ATTRIBUTE_NAME = 0; + public static final int PLAYER_ATTRIBUTE_PLAYBACK_INFO = 1; + public static final int PLAYER_ATTRIBUTE_VOLUME = 2; + + public static final int QUEUE_ATTRIBUTE_INDEX = 0; + public static final int QUEUE_ATTRIBUTE_COUNT = 1; + public static final int QUEUE_ATTRIBUTE_SHUFFLE_MODE = 2; + public static final int QUEUE_ATTRIBUTE_REPEAT_MODE = 3; + + public static final int TRACK_ATTRIBUTE_ARTIST = 0; + public static final int TRACK_ATTRIBUTE_ALBUM = 1; + public static final int TRACK_ATTRIBUTE_TITLE = 2; + public static final int TRACK_ATTRIBUTE_DURATION = 3; + + public final AmsEntity entity; + public final int attributeID; + public final int updateFlags; + public final byte[] value; + + public AmsEntityAttribute(AmsEntity entity, int attributeID, int updateFlags, String value) { + this.entity = entity; + this.attributeID = attributeID; + this.updateFlags = updateFlags; + this.value = value.getBytes(StandardCharsets.UTF_8); + if (this.value.length > 255) throw new IllegalArgumentException("Too long value"); + } + + public void writeToMessage(MessageWriter writer) { + writer.writeByte(entity.ordinal()); + writer.writeByte(attributeID); + writer.writeByte(updateFlags); + writer.writeByte(value.length); + writer.writeBytes(value); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAction.java new file mode 100644 index 000000000..b1ab5697f --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAction.java @@ -0,0 +1,6 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs; + +public enum AncsAction { + POSITIVE, + NEGATIVE +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAndroidAction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAndroidAction.java new file mode 100644 index 000000000..cee177053 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAndroidAction.java @@ -0,0 +1,32 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs; + +import android.util.SparseArray; + +public enum AncsAndroidAction { + REPLY_TEXT_MESSAGE(94), + REPLY_INCOMING_CALL(95), + ACCEPT_INCOMING_CALL(96), + REJECT_INCOMING_CALL(97), + DISMISS_NOTIFICATION(98), + BLOCK_APPLICATION(99); + + private static final SparseArray valueByCode; + + public final int code; + + AncsAndroidAction(int code) { + this.code = code; + } + + static { + final AncsAndroidAction[] values = values(); + valueByCode = new SparseArray<>(values.length); + for (AncsAndroidAction value : values) { + valueByCode.append(value.code, value); + } + } + + public static AncsAndroidAction getByCode(int code) { + return valueByCode.get(code); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAppAttribute.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAppAttribute.java new file mode 100644 index 000000000..7114e389e --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAppAttribute.java @@ -0,0 +1,5 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs; + +public enum AncsAppAttribute { + DISPLAY_NAME +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAttribute.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAttribute.java new file mode 100644 index 000000000..1ad038b30 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAttribute.java @@ -0,0 +1,49 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs; + +import android.util.SparseArray; + +public enum AncsAttribute { + APP_IDENTIFIER(0), + TITLE(1, true), + SUBTITLE(2, true), + MESSAGE(3, true), + MESSAGE_SIZE(4), + DATE(5), + POSITIVE_ACTION_LABEL(6), + NEGATIVE_ACTION_LABEL(7), + // Garmin extensions + PHONE_NUMBER(126, true), + ACTIONS(127, false, true); + + private static final SparseArray valueByCode; + + public final int code; + public final boolean hasLengthParam; + public final boolean hasAdditionalParams; + + AncsAttribute(int code) { + this(code, false, false); + } + + AncsAttribute(int code, boolean hasLengthParam) { + this(code, hasLengthParam, false); + } + + AncsAttribute(int code, boolean hasLengthParam, boolean hasAdditionalParams) { + this.code = code; + this.hasLengthParam = hasLengthParam; + this.hasAdditionalParams = hasAdditionalParams; + } + + static { + final AncsAttribute[] values = values(); + valueByCode = new SparseArray<>(values.length); + for (AncsAttribute value : values) { + valueByCode.append(value.code, value); + } + } + + public static AncsAttribute getByCode(int code) { + return valueByCode.get(code); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAttributeRequest.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAttributeRequest.java new file mode 100644 index 000000000..2e4418ad5 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsAttributeRequest.java @@ -0,0 +1,11 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs; + +public class AncsAttributeRequest { + public final AncsAttribute attribute; + public final int maxLength; + + public AncsAttributeRequest(AncsAttribute attribute, int maxLength) { + this.attribute = attribute; + this.maxLength = maxLength; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsCategory.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsCategory.java new file mode 100644 index 000000000..f926fd3ec --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsCategory.java @@ -0,0 +1,17 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs; + +public enum AncsCategory { + OTHER, + INCOMING_CALL, + MISSED_CALL, + VOICEMAIL, + SOCIAL, + SCHEDULE, + EMAIL, + NEWS, + HEALTH_AND_FITNESS, + BUSINESS_AND_FINANCE, + LOCATION, + ENTERTAINMENT, + SMS +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsCommand.java new file mode 100644 index 000000000..59078b939 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsCommand.java @@ -0,0 +1,31 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs; + +import android.util.SparseArray; + +public enum AncsCommand { + GET_NOTIFICATION_ATTRIBUTES(0), + GET_APP_ATTRIBUTES(1), + PERFORM_NOTIFICATION_ACTION(2), + // Garmin extensions + PERFORM_ANDROID_ACTION(128); + + private static final SparseArray valueByCode; + + public final int code; + + AncsCommand(int code) { + this.code = code; + } + + static { + final AncsCommand[] values = values(); + valueByCode = new SparseArray<>(values.length); + for (AncsCommand value : values) { + valueByCode.append(value.code, value); + } + } + + public static AncsCommand getByCode(int code) { + return valueByCode.get(code); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsControlCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsControlCommand.java new file mode 100644 index 000000000..d657fe82b --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsControlCommand.java @@ -0,0 +1,121 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs; + +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.MessageReader; +import nodomain.freeyourgadget.gadgetbridge.util.ArrayUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +public abstract class AncsControlCommand { + private static final Logger LOG = LoggerFactory.getLogger(AncsControlCommand.class); + + private static final AncsAppAttribute[] APP_ATTRIBUTE_VALUES = AncsAppAttribute.values(); + private static final AncsAction[] ACTION_VALUES = AncsAction.values(); + + public final AncsCommand command; + + protected AncsControlCommand(AncsCommand command) { + this.command = command; + } + + public static AncsControlCommand parseCommand(byte[] buffer, int offset, int size) { + final int commandID = BinaryUtils.readByte(buffer, offset); + final AncsCommand command = AncsCommand.getByCode(commandID); + if (command == null) { + LOG.error("Unknown ANCS command {}", commandID); + return null; + } + switch (command) { + case GET_NOTIFICATION_ATTRIBUTES: + return createGetNotificationAttributesCommand(buffer, offset + 1, size - 1); + case GET_APP_ATTRIBUTES: + return createGetAppAttributesCommand(buffer, offset + 1, size - 1); + case PERFORM_NOTIFICATION_ACTION: + return createPerformNotificationAction(buffer, offset + 1, size - 1); + case PERFORM_ANDROID_ACTION: + return createPerformAndroidAction(buffer, offset + 1, size - 1); + default: + LOG.error("Unknown ANCS command {}", command); + return null; + } + } + + private static AncsPerformAndroidAction createPerformAndroidAction(byte[] buffer, int offset, int size) { + final int notificationUID = BinaryUtils.readInt(buffer, offset); + final int actionID = BinaryUtils.readByte(buffer, offset + 4); + final AncsAndroidAction action = AncsAndroidAction.getByCode(actionID); + if (action == null) { + LOG.error("Unknown ANCS Android action {}", actionID); + return null; + } + int zero = ArrayUtils.indexOf((byte) 0, buffer, offset + 6, size - offset - 6); + if (zero < 0) zero = size; + final String text = new String(buffer, offset + 6, zero - offset - 6); + + return new AncsPerformAndroidAction(notificationUID, action, text); + } + + private static AncsPerformNotificationAction createPerformNotificationAction(byte[] buffer, int offset, int size) { + final MessageReader reader = new MessageReader(buffer, offset); + final int notificationUID = reader.readInt(); + final int actionID = reader.readByte(); + if (actionID < 0 || actionID >= ACTION_VALUES.length) { + LOG.error("Unknown ANCS action {}", actionID); + return null; + } + return new AncsPerformNotificationAction(notificationUID, ACTION_VALUES[actionID]); + } + + private static AncsGetAppAttributesCommand createGetAppAttributesCommand(byte[] buffer, int offset, int size) { + int zero = ArrayUtils.indexOf((byte) 0, buffer, offset, size - offset); + if (zero < 0) zero = size; + final String appIdentifier = new String(buffer, offset, zero - offset, StandardCharsets.UTF_8); + final int attributeCount = size - (zero - offset); + final List requestedAttributes = new ArrayList<>(attributeCount); + for (int i = 0; i < attributeCount; ++i) { + final int attributeID = BinaryUtils.readByte(buffer, zero + 1 + i); + if (attributeID < 0 || attributeID >= APP_ATTRIBUTE_VALUES.length) { + LOG.error("Unknown ANCS app attribute {}", attributeID); + return null; + } + final AncsAppAttribute attribute = APP_ATTRIBUTE_VALUES[attributeID]; + requestedAttributes.add(attribute); + } + return new AncsGetAppAttributesCommand(appIdentifier, requestedAttributes); + } + + private static AncsGetNotificationAttributeCommand createGetNotificationAttributesCommand(byte[] buffer, int offset, int size) { + final MessageReader reader = new MessageReader(buffer, offset); + final int notificationUID = reader.readInt(); + int pos = 4; + final List attributes = new ArrayList<>(size); + while (pos < size) { + final int attributeID = reader.readByte(); + ++pos; + final AncsAttribute attribute = AncsAttribute.getByCode(attributeID); + if (attribute == null) { + LOG.error("Unknown ANCS attribute {}", attributeID); + return null; + } + final int maxLength; + if (attribute.hasLengthParam) { + maxLength = reader.readShort(); + pos += 2; + } else if (attribute.hasAdditionalParams) { + maxLength = reader.readByte(); + // TODO: What is this?? + reader.readByte(); + reader.readByte(); + pos += 3; + } else { + maxLength = 0; + } + attributes.add(new AncsAttributeRequest(attribute, maxLength)); + } + return new AncsGetNotificationAttributeCommand(notificationUID, attributes); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsEvent.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsEvent.java new file mode 100644 index 000000000..c791dc536 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsEvent.java @@ -0,0 +1,7 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs; + +public enum AncsEvent { + NOTIFICATION_ADDED, + NOTIFICATION_MODIFIED, + NOTIFICATION_REMOVED +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsEventFlag.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsEventFlag.java new file mode 100644 index 000000000..6fbf68f12 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsEventFlag.java @@ -0,0 +1,9 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs; + +public enum AncsEventFlag { + SILENT, + IMPORTANT, + PRE_EXISTING, + POSITIVE_ACTION, + NEGATIVE_ACTION +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsGetAppAttributesCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsGetAppAttributesCommand.java new file mode 100644 index 000000000..c7ac8d65e --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsGetAppAttributesCommand.java @@ -0,0 +1,14 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs; + +import java.util.List; + +public class AncsGetAppAttributesCommand extends AncsControlCommand { + public final String appIdentifier; + public final List requestedAttributes; + + public AncsGetAppAttributesCommand(String appIdentifier, List requestedAttributes) { + super(AncsCommand.GET_APP_ATTRIBUTES); + this.appIdentifier = appIdentifier; + this.requestedAttributes = requestedAttributes; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsGetNotificationAttributeCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsGetNotificationAttributeCommand.java new file mode 100644 index 000000000..43c01a4aa --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsGetNotificationAttributeCommand.java @@ -0,0 +1,14 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs; + +import java.util.List; + +public class AncsGetNotificationAttributeCommand extends AncsControlCommand { + public final int notificationUID; + public final List attributes; + + public AncsGetNotificationAttributeCommand(int notificationUID, List attributes) { + super(AncsCommand.GET_NOTIFICATION_ATTRIBUTES); + this.notificationUID = notificationUID; + this.attributes = attributes; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsGetNotificationAttributesResponse.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsGetNotificationAttributesResponse.java new file mode 100644 index 000000000..7321361f3 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsGetNotificationAttributesResponse.java @@ -0,0 +1,23 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs; + +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.MessageWriter; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +public class AncsGetNotificationAttributesResponse { + public final byte[] packet; + + public AncsGetNotificationAttributesResponse(int notificationUID, Map attributes) { + final MessageWriter messageWriter = new MessageWriter(); + messageWriter.writeByte(AncsCommand.GET_NOTIFICATION_ATTRIBUTES.code); + messageWriter.writeInt(notificationUID); + for(Map.Entry attribute : attributes.entrySet()) { + messageWriter.writeByte(attribute.getKey().code); + final byte[] bytes = attribute.getValue().getBytes(StandardCharsets.UTF_8); + messageWriter.writeShort(bytes.length); + messageWriter.writeBytes(bytes); + } + this.packet = messageWriter.getBytes(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsPerformAndroidAction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsPerformAndroidAction.java new file mode 100644 index 000000000..af58e703f --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsPerformAndroidAction.java @@ -0,0 +1,14 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs; + +public class AncsPerformAndroidAction extends AncsControlCommand { + public final int notificationUID; + public final AncsAndroidAction action; + public final String text; + + public AncsPerformAndroidAction(int notificationUID, AncsAndroidAction action, String text) { + super(AncsCommand.PERFORM_ANDROID_ACTION); + this.notificationUID = notificationUID; + this.action = action; + this.text = text; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsPerformNotificationAction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsPerformNotificationAction.java new file mode 100644 index 000000000..487213c5e --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/AncsPerformNotificationAction.java @@ -0,0 +1,12 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs; + +public class AncsPerformNotificationAction extends AncsControlCommand { + public final int notificationUID; + public final AncsAction action; + + public AncsPerformNotificationAction(int notificationUID, AncsAction action) { + super(AncsCommand.PERFORM_NOTIFICATION_ACTION); + this.notificationUID = notificationUID; + this.action = action; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/GncsDataSourceQueue.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/GncsDataSourceQueue.java new file mode 100644 index 000000000..1b6f6035e --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/ancs/GncsDataSourceQueue.java @@ -0,0 +1,97 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs; + +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.VivomoveHrCommunicator; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.GncsDataSourceMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.GncsDataSourceResponseMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.LinkedList; +import java.util.Queue; + +public class GncsDataSourceQueue { + private static final Logger LOG = LoggerFactory.getLogger(GncsDataSourceQueue.class); + + private final VivomoveHrCommunicator communicator; + private final int maxPacketSize; + private final Queue queue = new LinkedList<>(); + + private byte[] currentPacket; + private int currentDataOffset; + private int lastSentSize; + + public GncsDataSourceQueue(VivomoveHrCommunicator communicator, int maxPacketSize) { + this.communicator = communicator; + this.maxPacketSize = maxPacketSize; + } + + public void addToQueue(byte[] packet) { + queue.add(packet); + checkStartUpload(); + } + + public void responseReceived(GncsDataSourceResponseMessage responseMessage) { + if (currentPacket == null) { + LOG.error("Unexpected GNCS data source response, no current packet"); + return; + } + switch (responseMessage.response) { + case GncsDataSourceResponseMessage.RESPONSE_TRANSFER_SUCCESSFUL: + LOG.debug("Confirmed {}B@{} GNCS transfer", lastSentSize, currentDataOffset); + currentDataOffset += lastSentSize; + if (currentDataOffset >= currentPacket.length) { + LOG.debug("ANCS packet transfer done"); + currentPacket = null; + checkStartUpload(); + } else { + sendNextMessage(); + } + break; + + case GncsDataSourceResponseMessage.RESPONSE_RESEND_LAST_DATA_PACKET: + LOG.info("Received RESEND_LAST_DATA_PACKET GNCS response"); + sendNextMessage(); + break; + + case GncsDataSourceResponseMessage.RESPONSE_ABORT_REQUEST: + LOG.info("Received RESPONSE_ABORT_REQUEST GNCS response"); + currentPacket = null; + checkStartUpload(); + break; + + case GncsDataSourceResponseMessage.RESPONSE_ERROR_CRC_MISMATCH: + case GncsDataSourceResponseMessage.RESPONSE_ERROR_DATA_OFFSET_MISMATCH: + default: + LOG.error("Received {} GNCS response", responseMessage.response); + currentPacket = null; + checkStartUpload(); + break; + } + } + + private void checkStartUpload() { + if (currentPacket != null) { + LOG.debug("Another upload is still running"); + return; + } + if (queue.isEmpty()) { + LOG.debug("Nothing in queue"); + return; + } + startNextUpload(); + } + + private void startNextUpload() { + currentPacket = queue.remove(); + currentDataOffset = 0; + LOG.debug("Sending {}B ANCS data", currentPacket.length); + sendNextMessage(); + } + + private void sendNextMessage() { + final int remainingSize = currentPacket.length - currentDataOffset; + final int availableSize = Math.min(remainingSize, maxPacketSize); + communicator.sendMessage(new GncsDataSourceMessage(currentPacket, currentDataOffset, Math.min(remainingSize, maxPacketSize)).packet); + lastSentSize = availableSize; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/downloads/DirectoryData.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/downloads/DirectoryData.java new file mode 100644 index 000000000..e5df6a211 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/downloads/DirectoryData.java @@ -0,0 +1,38 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.downloads; + +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.GarminTimeUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.MessageReader; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +public class DirectoryData { + public final List entries; + + public DirectoryData(List entries) { + this.entries = entries; + } + + public static DirectoryData parse(byte[] bytes) { + int size = bytes.length; + if ((size % 16) != 0) throw new IllegalArgumentException("Invalid directory data length"); + int count = (size - 16) / 16; + final MessageReader reader = new MessageReader(bytes, 16); + final List entries = new ArrayList<>(count); + for (int i = 0; i < count; ++i) { + final int fileIndex = reader.readShort(); + final int fileDataType = reader.readByte(); + final int fileSubType = reader.readByte(); + final int fileNumber = reader.readShort(); + final int specificFlags = reader.readByte(); + final int fileFlags = reader.readByte(); + final int fileSize = reader.readInt(); + final Date fileDate = new Date(GarminTimeUtils.garminTimestampToJavaMillis(reader.readInt())); + + entries.add(new DirectoryEntry(fileIndex, fileDataType, fileSubType, fileNumber, specificFlags, fileFlags, fileSize, fileDate)); + } + + return new DirectoryData(entries); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/downloads/DirectoryEntry.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/downloads/DirectoryEntry.java new file mode 100644 index 000000000..01a52af59 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/downloads/DirectoryEntry.java @@ -0,0 +1,25 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.downloads; + +import java.util.Date; + +public class DirectoryEntry { + public final int fileIndex; + public final int fileDataType; + public final int fileSubType; + public final int fileNumber; + public final int specificFlags; + public final int fileFlags; + public final int fileSize; + public final Date fileDate; + + public DirectoryEntry(int fileIndex, int fileDataType, int fileSubType, int fileNumber, int specificFlags, int fileFlags, int fileSize, Date fileDate) { + this.fileIndex = fileIndex; + this.fileDataType = fileDataType; + this.fileSubType = fileSubType; + this.fileNumber = fileNumber; + this.specificFlags = specificFlags; + this.fileFlags = fileFlags; + this.fileSize = fileSize; + this.fileDate = fileDate; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/downloads/FileDownloadListener.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/downloads/FileDownloadListener.java new file mode 100644 index 000000000..9e70aac37 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/downloads/FileDownloadListener.java @@ -0,0 +1,9 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.downloads; + +public interface FileDownloadListener { + void onDirectoryDownloaded(DirectoryData directoryData); + void onFileDownloadComplete(int fileIndex, byte[] data); + void onFileDownloadError(int fileIndex); + void onDownloadProgress(long remainingBytes); + void onAllDownloadsCompleted(); +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/downloads/FileDownloadQueue.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/downloads/FileDownloadQueue.java new file mode 100644 index 000000000..63538bad9 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/downloads/FileDownloadQueue.java @@ -0,0 +1,172 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.downloads; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.VivomoveHrCommunicator; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.DownloadRequestMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.DownloadRequestResponseMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.FileTransferDataMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.FileTransferDataResponseMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashSet; +import java.util.LinkedList; +import java.util.Queue; +import java.util.Set; + +public class FileDownloadQueue { + private static final Logger LOG = LoggerFactory.getLogger(FileDownloadQueue.class); + + private final VivomoveHrCommunicator communicator; + private final FileDownloadListener listener; + + private final Queue queue = new LinkedList<>(); + private final Set queuedFileIndices = new HashSet<>(); + + private QueueItem currentlyDownloadingItem; + private int currentCrc; + private long totalRemainingBytes; + + public FileDownloadQueue(VivomoveHrCommunicator communicator, FileDownloadListener listener) { + this.communicator = communicator; + this.listener = listener; + } + + public void addToDownloadQueue(int fileIndex, int dataSize) { + if (queuedFileIndices.contains(fileIndex)) { + LOG.debug("Ignoring download request of {}, already in queue", fileIndex); + return; + } + queue.add(new QueueItem(fileIndex, dataSize)); + queuedFileIndices.add(fileIndex); + totalRemainingBytes += dataSize; + checkRequestNextDownload(); + } + + public void cancelAllDownloads() { + queue.clear(); + currentlyDownloadingItem = null; + communicator.sendMessage(new FileTransferDataResponseMessage(VivomoveConstants.STATUS_ACK, FileTransferDataResponseMessage.RESPONSE_ABORT_DOWNLOAD_REQUEST, 0).packet); + } + + private boolean checkRequestNextDownload() { + if (currentlyDownloadingItem != null) { + LOG.debug("Another download is pending"); + return false; + } + if (queue.isEmpty()) { + LOG.debug("No download in queue"); + return true; + } + requestNextDownload(); + return false; + } + + private void requestNextDownload() { + currentlyDownloadingItem = queue.remove(); + currentCrc = 0; + final int fileIndex = currentlyDownloadingItem.fileIndex; + LOG.info("Requesting download of {} ({} B)", fileIndex, currentlyDownloadingItem.dataSize); + queuedFileIndices.remove(fileIndex); + communicator.sendMessage(new DownloadRequestMessage(fileIndex, 0, DownloadRequestMessage.REQUEST_NEW_TRANSFER, 0, 0).packet); + } + + public void onDownloadRequestResponse(DownloadRequestResponseMessage responseMessage) { + if (currentlyDownloadingItem == null) { + LOG.error("Download request response arrived, but nothing is being downloaded"); + return; + } + + if (responseMessage.status == VivomoveConstants.STATUS_ACK && responseMessage.response == DownloadRequestResponseMessage.RESPONSE_DOWNLOAD_REQUEST_OKAY) { + LOG.info("Received response for download request of {}: {}/{}, {}B", currentlyDownloadingItem.fileIndex, responseMessage.status, responseMessage.response, responseMessage.fileSize); + totalRemainingBytes += responseMessage.fileSize - currentlyDownloadingItem.dataSize; + currentlyDownloadingItem.setDataSize(responseMessage.fileSize); + } else { + LOG.error("Received error response for download request of {}: {}/{}", currentlyDownloadingItem.fileIndex, responseMessage.status, responseMessage.response); + listener.onFileDownloadError(currentlyDownloadingItem.fileIndex); + totalRemainingBytes -= currentlyDownloadingItem.dataSize; + currentlyDownloadingItem = null; + checkRequestNextDownload(); + } + } + + public void onFileTransferData(FileTransferDataMessage dataMessage) { + final QueueItem currentlyDownloadingItem = this.currentlyDownloadingItem; + if (currentlyDownloadingItem == null) { + LOG.error("Download request response arrived, but nothing is being downloaded"); + communicator.sendMessage(new FileTransferDataResponseMessage(VivomoveConstants.STATUS_ACK, FileTransferDataResponseMessage.RESPONSE_ABORT_DOWNLOAD_REQUEST, 0).packet); + return; + } + + if (dataMessage.dataOffset < currentlyDownloadingItem.dataOffset) { + LOG.warn("Ignoring repeated transfer at offset {} of #{}", dataMessage.dataOffset, currentlyDownloadingItem.fileIndex); + communicator.sendMessage(new FileTransferDataResponseMessage(VivomoveConstants.STATUS_ACK, FileTransferDataResponseMessage.RESPONSE_ERROR_DATA_OFFSET_MISMATCH, currentlyDownloadingItem.dataOffset).packet); + return; + } + if (dataMessage.dataOffset > currentlyDownloadingItem.dataOffset) { + LOG.warn("Missing data at offset {} when received data at offset {} of #{}", currentlyDownloadingItem.dataOffset, dataMessage.dataOffset, currentlyDownloadingItem.fileIndex); + communicator.sendMessage(new FileTransferDataResponseMessage(VivomoveConstants.STATUS_ACK, FileTransferDataResponseMessage.RESPONSE_ERROR_DATA_OFFSET_MISMATCH, currentlyDownloadingItem.dataOffset).packet); + return; + } + + final int dataCrc = ChecksumCalculator.computeCrc(currentCrc, dataMessage.data, 0, dataMessage.data.length); + if (dataCrc != dataMessage.crc) { + LOG.warn("Invalid CRC ({} vs {}) for {}B data @{} of {}", dataCrc, dataMessage.crc, dataMessage.data.length, dataMessage.dataOffset, currentlyDownloadingItem.fileIndex); + communicator.sendMessage(new FileTransferDataResponseMessage(VivomoveConstants.STATUS_ACK, FileTransferDataResponseMessage.RESPONSE_ERROR_CRC_MISMATCH, currentlyDownloadingItem.dataOffset).packet); + return; + } + currentCrc = dataCrc; + + LOG.info("Received {}B@{}/{} of {}", dataMessage.data.length, dataMessage.dataOffset, currentlyDownloadingItem.dataSize, currentlyDownloadingItem.fileIndex); + currentlyDownloadingItem.appendData(dataMessage.data); + communicator.sendMessage(new FileTransferDataResponseMessage(VivomoveConstants.STATUS_ACK, FileTransferDataResponseMessage.RESPONSE_TRANSFER_SUCCESSFUL, currentlyDownloadingItem.dataOffset).packet); + + totalRemainingBytes -= dataMessage.data.length; + listener.onDownloadProgress(totalRemainingBytes); + + if (currentlyDownloadingItem.dataOffset >= currentlyDownloadingItem.dataSize) { + LOG.info("Transfer of file #{} complete, {}/{}B downloaded", currentlyDownloadingItem.fileIndex, currentlyDownloadingItem.dataOffset, currentlyDownloadingItem.dataSize); + this.currentlyDownloadingItem = null; + final boolean allDone = checkRequestNextDownload(); + reportCompletedDownload(currentlyDownloadingItem); + if (allDone && isIdle()) listener.onAllDownloadsCompleted(); + } + } + + private boolean isIdle() { + return currentlyDownloadingItem == null; + } + + private void reportCompletedDownload(QueueItem downloadedItem) { + if (downloadedItem.fileIndex == 0) { + final DirectoryData directoryData = DirectoryData.parse(downloadedItem.data); + listener.onDirectoryDownloaded(directoryData); + } else { + listener.onFileDownloadComplete(downloadedItem.fileIndex, downloadedItem.data); + } + } + + private static class QueueItem { + public final int fileIndex; + public int dataSize; + public int dataOffset; + public byte[] data; + + public QueueItem(int fileIndex, int dataSize) { + this.fileIndex = fileIndex; + this.dataSize = dataSize; + } + + public void setDataSize(int dataSize) { + if (this.data != null) throw new IllegalStateException("Data size already set"); + this.dataSize = dataSize; + this.data = new byte[dataSize]; + } + + public void appendData(byte[] data) { + System.arraycopy(data, 0, this.data, dataOffset, data.length); + dataOffset += data.length; + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitBool.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitBool.java new file mode 100644 index 000000000..d9fc62613 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitBool.java @@ -0,0 +1,7 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit; + +public final class FitBool { + public static final int FALSE = 0; + public static final int TRUE = 1; + public static final int INVALID = 255; +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitDbImporter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitDbImporter.java new file mode 100644 index 000000000..e8207dc5e --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitDbImporter.java @@ -0,0 +1,54 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; +import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveHrSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.Device; +import nodomain.freeyourgadget.gadgetbridge.entities.User; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +public class FitDbImporter { + private static final Logger LOG = LoggerFactory.getLogger(FitDbImporter.class); + + private final GBDevice gbDevice; + private final FitImporter fitImporter; + + public FitDbImporter(GBDevice gbDevice) { + this.gbDevice = gbDevice; + fitImporter = new FitImporter(); + } + + public void processFitFile(List messages) { + try { + fitImporter.importFitData(messages); + } catch (Exception e) { + LOG.error("Error importing FIT data", e); + } + } + + public void processData() { + try (DBHandler dbHandler = GBApplication.acquireDB()) { + final DaoSession session = dbHandler.getDaoSession(); + + final Device device = DBHelper.getDevice(gbDevice, session); + final User user = DBHelper.getUser(session); + final VivomoveHrSampleProvider provider = new VivomoveHrSampleProvider(gbDevice, session); + + fitImporter.processImportedData(sample -> { + sample.setDevice(device); + sample.setUser(user); + sample.setProvider(provider); + + provider.addGBActivitySample(sample); + }); + } catch (Exception e) { + LOG.error("Error importing FIT data", e); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitFieldBaseType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitFieldBaseType.java new file mode 100644 index 000000000..fa5d788bc --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitFieldBaseType.java @@ -0,0 +1,53 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit; + +import android.util.SparseArray; + +public enum FitFieldBaseType { + ENUM(0, 1, 0xFF), + SINT8(1, 1, 0x7F), + UINT8(2, 1, 0xFF), + SINT16(3, 2, 0x7FFF), + UINT16(4, 2, 0xFFFF), + SINT32(5, 4, 0x7FFFFFFF), + UINT32(6, 4, 0xFFFFFFFF), + STRING(7, 1, ""), + FLOAT32(8, 4, 0xFFFFFFFF), + FLOAT64(9, 8, 0xFFFFFFFFFFFFFFFFL), + UINT8Z(10, 1, 0), + UINT16Z(11, 2, 0), + UINT32Z(12, 4, 0), + BYTE(13, 1, 0xFF), + SINT64(14, 8, 0x7FFFFFFFFFFFFFFFL), + UINT64(15, 8, 0xFFFFFFFFFFFFFFFFL), + UINT64Z(16, 8, 0); + + public final int typeNumber; + public final int size; + public final int typeID; + public final Object invalidValue; + + private static final SparseArray typeForCode = new SparseArray<>(values().length); + private static final SparseArray typeForID = new SparseArray<>(values().length); + + static { + for (FitFieldBaseType value : values()) { + typeForCode.append(value.typeNumber, value); + typeForID.append(value.typeID, value); + } + } + + FitFieldBaseType(int typeNumber, int size, Object invalidValue) { + this.typeNumber = typeNumber; + this.size = size; + this.invalidValue = invalidValue; + this.typeID = size > 1 ? (typeNumber | 0x80) : typeNumber; + } + + public static FitFieldBaseType decodeTypeID(int typeNumber) { + final FitFieldBaseType type = typeForID.get(typeNumber); + if (type == null) { + throw new IllegalArgumentException("Unknown type " + typeNumber); + } + return type; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitImportProcessor.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitImportProcessor.java new file mode 100644 index 000000000..d9e7c68fa --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitImportProcessor.java @@ -0,0 +1,7 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit; + +import nodomain.freeyourgadget.gadgetbridge.entities.VivomoveHrActivitySample; + +interface FitImportProcessor { + void onSample(VivomoveHrActivitySample sample); +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitImporter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitImporter.java new file mode 100644 index 000000000..07ecf0927 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitImporter.java @@ -0,0 +1,272 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit; + +import android.util.SparseIntArray; +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveHrSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.VivomoveHrActivitySample; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.GarminTimeUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.SortedMap; +import java.util.TreeMap; + +public class FitImporter { + private static final int ACTIVITY_TYPE_ALL = -1; + private final SortedMap> eventsPerTimestamp = new TreeMap<>(); + + public void importFitData(List messages) { + boolean ohrEnabled = false; + int softwareVersion = -1; + + int lastTimestamp = 0; + final SparseIntArray lastCycles = new SparseIntArray(); + + for (FitMessage message : messages) { + switch (message.definition.globalMessageID) { + case FitMessageDefinitions.FIT_MESSAGE_NUMBER_EVENT: + //message.getField(); + break; + + case FitMessageDefinitions.FIT_MESSAGE_NUMBER_SOFTWARE: + final Integer versionField = message.getIntegerField("version"); + if (versionField != null) softwareVersion = versionField; + break; + + case FitMessageDefinitions.FIT_MESSAGE_NUMBER_MONITORING_INFO: + lastTimestamp = message.getIntegerField("timestamp"); + break; + + case FitMessageDefinitions.FIT_MESSAGE_NUMBER_MONITORING: + lastTimestamp = processMonitoringMessage(message, ohrEnabled, lastTimestamp, lastCycles); + break; + + case FitMessageDefinitions.FIT_MESSAGE_NUMBER_OHR_SETTINGS: + final Boolean isOhrEnabled = message.getBooleanField("enabled"); + if (isOhrEnabled != null) ohrEnabled = isOhrEnabled; + break; + + case FitMessageDefinitions.FIT_MESSAGE_NUMBER_SLEEP_LEVEL: + processSleepLevelMessage(message); + break; + + case FitMessageDefinitions.FIT_MESSAGE_NUMBER_MONITORING_HR_DATA: + processHrDataMessage(message); + break; + + case FitMessageDefinitions.FIT_MESSAGE_NUMBER_STRESS_LEVEL: + processStressLevelMessage(message); + break; + + case FitMessageDefinitions.FIT_MESSAGE_NUMBER_MAX_MET_DATA: + processMaxMetDataMessage(message); + break; + } + } + } + + public void processImportedData(FitImportProcessor processor) { + for (final Map.Entry> eventsForTimestamp : eventsPerTimestamp.entrySet()) { + final VivomoveHrActivitySample sample = new VivomoveHrActivitySample(); + sample.setTimestamp(eventsForTimestamp.getKey()); + + sample.setRawKind(ActivitySample.NOT_MEASURED); + sample.setCaloriesBurnt(ActivitySample.NOT_MEASURED); + sample.setSteps(ActivitySample.NOT_MEASURED); + sample.setHeartRate(ActivitySample.NOT_MEASURED); + sample.setFloorsClimbed(ActivitySample.NOT_MEASURED); + sample.setRawIntensity(ActivitySample.NOT_MEASURED); + + FitEvent.EventKind bestKind = FitEvent.EventKind.UNKNOWN; + float bestScore = Float.NEGATIVE_INFINITY; + for (final FitEvent event : eventsForTimestamp.getValue()) { + if (event.getHeartRate() > sample.getHeartRate()) { + sample.setHeartRate(event.getHeartRate()); + } + if (event.getFloorsClimbed() > sample.getFloorsClimbed()) { + sample.setFloorsClimbed(event.getFloorsClimbed()); + } + + float score = 0; + if (event.getRawKind() > 0) score += 1; + if (event.getCaloriesBurnt() > 0) score += event.getCaloriesBurnt() * 10.0f; + if (event.getSteps() > 0) score += event.getSteps(); + //if (event.getRawIntensity() > 0) score += 10.0f * event.getRawIntensity(); + if (event.getKind().isBetterThan(bestKind) || (event.getKind() == bestKind && score > bestScore)) { +// if (bestScore > Float.NEGATIVE_INFINITY && event.getKind() != FitEvent.EventKind.NOT_WORN) { +// System.out.println(String.format(Locale.ROOT, "Replacing %s %d (%d cal, %d steps) with %s %d (%d cal, %d steps)", sample.getRawKind(), sample.getRawIntensity(), sample.getCaloriesBurnt(), sample.getSteps(), event.getRawKind(), event.getRawIntensity(), event.getCaloriesBurnt(), event.getSteps())); +// } + bestScore = score; + bestKind = event.getKind(); + sample.setRawKind(event.getRawKind()); + sample.setCaloriesBurnt(event.getCaloriesBurnt()); + sample.setSteps(event.getSteps()); + sample.setRawIntensity(event.getRawIntensity()); + } + } + + if (sample.getHeartRate() == ActivitySample.NOT_MEASURED && ((sample.getRawKind() & VivomoveHrSampleProvider.RAW_TYPE_KIND_SLEEP) != 0)) { + sample.setRawKind(VivomoveHrSampleProvider.RAW_NOT_WORN); + sample.setRawIntensity(0); + } + + processor.onSample(sample); + } + } + + private void processSleepLevelMessage(FitMessage message) { + final Integer timestampFull = message.getIntegerField("timestamp"); + final Integer sleepLevel = message.getIntegerField("sleep_level"); + + final int timestamp = GarminTimeUtils.garminTimestampToUnixTime(timestampFull); + final int rawIntensity = (4 - sleepLevel) * 40; + final int rawKind = VivomoveHrSampleProvider.RAW_TYPE_KIND_SLEEP | sleepLevel; + + addEvent(new FitEvent(timestamp, FitEvent.EventKind.SLEEP, rawKind, ActivitySample.NOT_MEASURED, ActivitySample.NOT_MEASURED, ActivitySample.NOT_MEASURED, ActivitySample.NOT_MEASURED, rawIntensity)); + } + + private int processMonitoringMessage(FitMessage message, boolean ohrEnabled, int lastTimestamp, SparseIntArray lastCycles) { + final Integer activityType = message.getIntegerField("activity_type"); + final Double activeCalories = message.getNumericField("active_calories"); + final Integer intensity = message.getIntegerField("current_activity_type_intensity"); + final Integer cycles = message.getIntegerField("cycles"); + final Double heartRateMeasured = message.getNumericField("heart_rate"); + final Integer timestampFull = message.getIntegerField("timestamp"); + final Integer timestamp16 = message.getIntegerField("timestamp_16"); + final Double activeTime = message.getNumericField("active_time"); + + final int activityTypeOrAll = activityType == null ? ACTIVITY_TYPE_ALL : activityType; + final int activityTypeOrDefault = activityType == null ? 0 : activityType; + + final int lastDefaultCycleCount = lastCycles.get(ACTIVITY_TYPE_ALL); + final int lastCycleCount = Math.max(lastCycles.get(activityTypeOrAll), lastDefaultCycleCount); + final Integer currentCycles = cycles == null ? null : cycles < lastCycleCount ? cycles : cycles - lastCycleCount; + if (currentCycles != null) { + lastCycles.put(activityTypeOrDefault, cycles); + final int newAllCycles = Math.max(lastDefaultCycleCount, cycles); + if (newAllCycles != lastDefaultCycleCount) { + assert newAllCycles > lastDefaultCycleCount; + lastCycles.put(ACTIVITY_TYPE_ALL, newAllCycles); + } + } + + if (timestampFull != null) { + lastTimestamp = timestampFull; + } else if (timestamp16 != null) { + lastTimestamp += (timestamp16 - (lastTimestamp & 0xFFFF)) & 0xFFFF; + } else { + // TODO: timestamp_min_8 + throw new IllegalArgumentException("Unsupported timestamp"); + } + + final int timestamp = GarminTimeUtils.garminTimestampToUnixTime(lastTimestamp); + final int rawKind, caloriesBurnt, floorsClimbed, heartRate, steps, rawIntensity; + final FitEvent.EventKind eventKind; + + caloriesBurnt = activeCalories == null ? ActivitySample.NOT_MEASURED : (int) Math.round(activeCalories); + floorsClimbed = ActivitySample.NOT_MEASURED; + heartRate = ohrEnabled && heartRateMeasured != null && heartRateMeasured > 0 ? (int) Math.round(heartRateMeasured) : ActivitySample.NOT_MEASURED; + steps = currentCycles == null ? ActivitySample.NOT_MEASURED : currentCycles; + rawIntensity = intensity == null ? 0 : intensity; + rawKind = VivomoveHrSampleProvider.RAW_TYPE_KIND_ACTIVITY | activityTypeOrDefault; + eventKind = steps != ActivitySample.NOT_MEASURED || rawIntensity > 0 || activityTypeOrDefault > 0 ? FitEvent.EventKind.ACTIVITY : FitEvent.EventKind.WORN; + + if (rawKind != ActivitySample.NOT_MEASURED + || caloriesBurnt != ActivitySample.NOT_MEASURED + || floorsClimbed != ActivitySample.NOT_MEASURED + || heartRate != ActivitySample.NOT_MEASURED + || steps != ActivitySample.NOT_MEASURED + || rawIntensity != ActivitySample.NOT_MEASURED) { + + addEvent(new FitEvent(timestamp, eventKind, rawKind, caloriesBurnt, floorsClimbed, heartRate, steps, rawIntensity)); + } else { + addEvent(new FitEvent(timestamp, FitEvent.EventKind.NOT_WORN, VivomoveHrSampleProvider.RAW_NOT_WORN, ActivitySample.NOT_MEASURED, ActivitySample.NOT_MEASURED, ActivitySample.NOT_MEASURED, ActivitySample.NOT_MEASURED, ActivitySample.NOT_MEASURED)); + } + return lastTimestamp; + } + + private void processHrDataMessage(FitMessage message) { + } + + private void processStressLevelMessage(FitMessage message) { + } + + private void processMaxMetDataMessage(FitMessage message) { + } + + private void addEvent(FitEvent event) { + List eventsForTimestamp = eventsPerTimestamp.get(event.getTimestamp()); + if (eventsForTimestamp == null) { + eventsForTimestamp = new ArrayList<>(); + eventsPerTimestamp.put(event.getTimestamp(), eventsForTimestamp); + } + eventsForTimestamp.add(event); + } + + private static class FitEvent { + private final int timestamp; + private final EventKind kind; + private final int rawKind; + private final int caloriesBurnt; + private final int floorsClimbed; + private final int heartRate; + private final int steps; + private final int rawIntensity; + + private FitEvent(int timestamp, EventKind kind, int rawKind, int caloriesBurnt, int floorsClimbed, int heartRate, int steps, int rawIntensity) { + this.timestamp = timestamp; + this.kind = kind; + this.rawKind = rawKind; + this.caloriesBurnt = caloriesBurnt; + this.floorsClimbed = floorsClimbed; + this.heartRate = heartRate; + this.steps = steps; + this.rawIntensity = rawIntensity; + } + + public int getTimestamp() { + return timestamp; + } + + public EventKind getKind() { + return kind; + } + + public int getRawKind() { + return rawKind; + } + + public int getCaloriesBurnt() { + return caloriesBurnt; + } + + public int getFloorsClimbed() { + return floorsClimbed; + } + + public int getHeartRate() { + return heartRate; + } + + public int getSteps() { + return steps; + } + + public int getRawIntensity() { + return rawIntensity; + } + + public enum EventKind { + UNKNOWN, + NOT_WORN, + WORN, + SLEEP, + ACTIVITY; + + public boolean isBetterThan(EventKind other) { + return ordinal() > other.ordinal(); + } + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitLocalFieldDefinition.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitLocalFieldDefinition.java new file mode 100644 index 000000000..7a54fe6fb --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitLocalFieldDefinition.java @@ -0,0 +1,13 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit; + +class FitLocalFieldDefinition { + public final FitMessageFieldDefinition globalDefinition; + public final int size; + public final FitFieldBaseType baseType; + + FitLocalFieldDefinition(FitMessageFieldDefinition globalDefinition, int size, FitFieldBaseType baseType) { + this.globalDefinition = globalDefinition; + this.size = size; + this.baseType = baseType; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitLocalMessageDefinition.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitLocalMessageDefinition.java new file mode 100644 index 000000000..ed29550e1 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitLocalMessageDefinition.java @@ -0,0 +1,13 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit; + +import java.util.List; + +class FitLocalMessageDefinition { + public final FitMessageDefinition globalDefinition; + public final List fieldDefinitions; + + FitLocalMessageDefinition(FitMessageDefinition globalDefinition, List fieldDefinitions) { + this.globalDefinition = globalDefinition; + this.fieldDefinitions = fieldDefinitions; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitMessage.java new file mode 100644 index 000000000..d8f809570 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitMessage.java @@ -0,0 +1,165 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit; + +import android.util.SparseArray; +import androidx.annotation.NonNull; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.MessageWriter; + +import java.lang.reflect.Array; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +public class FitMessage { + public final FitMessageDefinition definition; + private final SparseArray fieldValues = new SparseArray<>(); + private final Map fieldValuesPerName = new HashMap<>(); + + public FitMessage(FitMessageDefinition definition) { + this.definition = definition; + } + + public void setField(int fieldNumber, Object value) { + // TODO: Support arrays + fieldValues.append(fieldNumber, value); + final FitMessageFieldDefinition fieldDefinition = definition.getField(fieldNumber); + fieldValuesPerName.put(fieldDefinition.fieldName, value); + } + + public void setField(String fieldName, Object value) { + final FitMessageFieldDefinition fieldDefinition = definition.findField(fieldName); + if (fieldDefinition == null) throw new IllegalArgumentException("Unknown field name " + fieldName); + setField(fieldDefinition.fieldNumber, value); + } + + public Object getField(int fieldNumber) { + return fieldValues.get(fieldNumber); + } + + public Object getField(String fieldName) { + return fieldValuesPerName.get(fieldName); + } + + public String getStringField(String fieldName) { + return (String) getField(fieldName); + } + + public Integer getIntegerField(String fieldName) { + return (Integer) getField(fieldName); + } + + public Double getNumericField(String fieldName) { + return (Double) getField(fieldName); + } + + public Boolean getBooleanField(String fieldName) { + final Integer value = (Integer) getField(fieldName); + if (value == null) return null; + int v = value; + return v == FitBool.INVALID ? null : (v != 0); + } + + public boolean isBooleanFieldTrue(String fieldName) { + final Boolean value = getBooleanField(fieldName); + return value != null && value; + } + + public void writeToMessage(MessageWriter writer) { + writer.writeByte(definition.localMessageID); + for (FitMessageFieldDefinition fieldDefinition : definition.fieldDefinitions) { + final Object value = fieldValues.get(fieldDefinition.fieldNumber, fieldDefinition.defaultValue); + writeFitValueToMessage(writer, value, fieldDefinition.fieldType, fieldDefinition.fieldSize); + } + } + + private static void writeFitValueToMessage(MessageWriter writer, Object value, FitFieldBaseType type, int size) { + switch (type) { + case ENUM: + case SINT8: + case UINT8: + case SINT16: + case UINT16: + case SINT32: + case UINT32: + case UINT8Z: + case UINT16Z: + case UINT32Z: + case BYTE: + writeFitNumberToMessage(writer, (Integer) value, size); + break; + case SINT64: + case UINT64: + case UINT64Z: + writeFitNumberToMessage(writer, (Long) value, size); + break; + case STRING: + writeFitStringToMessage(writer, (String) value, size); + break; + // TODO: Float data types + default: + throw new IllegalArgumentException("Unable to write value of type " + type); + } + } + + private static void writeFitStringToMessage(MessageWriter writer, String value, int size) { + final byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + int valueSize = Math.min(bytes.length, size - 1); + writer.writeBytes(bytes, 0, valueSize); + for (int i = valueSize; i < size; ++i) { + writer.writeByte(0); + } + } + + private static void writeFitNumberToMessage(MessageWriter writer, long value, int size) { + switch (size) { + case 1: + writer.writeByte((int) value); + break; + case 2: + writer.writeShort((int) value); + break; + case 4: + writer.writeInt((int) value); + break; + case 8: + writer.writeLong(value); + break; + default: + throw new IllegalArgumentException("Unable to write number of size " + size); + } + } + + @Override + @NonNull + public String toString() { + final StringBuilder result = new StringBuilder(); + result.append(this.definition.messageName); + result.append(System.lineSeparator()); + for (Map.Entry field : fieldValuesPerName.entrySet()) { + result.append('\t'); + result.append(field.getKey()); + result.append(": "); + result.append(valueToString(field.getValue())); + result.append(System.lineSeparator()); + } + return result.toString(); + } + + @NonNull + private static String valueToString(Object value) { + if (value == null) return "null"; + final Class clazz = value.getClass(); + if (clazz.isArray()) { + final StringBuilder result = new StringBuilder(); + result.append('['); + final int length = Array.getLength(value); + for (int i = 0; i < length; ++i) { + if (i > 0) result.append(", "); + result.append(valueToString(Array.get(value, i))); + } + result.append(']'); + return result.toString(); + } else { + return String.valueOf(value); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitMessageDefinition.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitMessageDefinition.java new file mode 100644 index 000000000..214c6a04b --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitMessageDefinition.java @@ -0,0 +1,55 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit; + +import android.util.SparseArray; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.MessageWriter; + +import java.util.Arrays; +import java.util.List; + +public class FitMessageDefinition { + public final String messageName; + public final int globalMessageID; + public final int localMessageID; + public final List fieldDefinitions; + public final SparseArray fieldsPerNumber; + + public FitMessageDefinition(String messageName, int globalMessageID, int localMessageID, FitMessageFieldDefinition... fieldDefinitions) { + this.messageName = messageName; + this.globalMessageID = globalMessageID; + this.localMessageID = localMessageID; + this.fieldDefinitions = Arrays.asList(fieldDefinitions); + fieldsPerNumber = new SparseArray<>(fieldDefinitions.length); + for (FitMessageFieldDefinition fieldDefinition : fieldDefinitions) { + addField(fieldDefinition); + } + } + + public FitMessageFieldDefinition getField(int fieldNumber) { + return fieldsPerNumber.get(fieldNumber); + } + + public void writeToMessage(MessageWriter writer) { + writer.writeByte(localMessageID | 0x40); + writer.writeByte(0); + writer.writeByte(0); + writer.writeShort(globalMessageID); + writer.writeByte(fieldDefinitions.size()); + for (FitMessageFieldDefinition fieldDefinition : fieldDefinitions) { + fieldDefinition.writeToMessage(writer); + } + } + + public void addField(FitMessageFieldDefinition fieldDefinition) { + if (fieldsPerNumber.get(fieldDefinition.fieldNumber) != null) { + throw new IllegalArgumentException("Duplicate field number " + fieldDefinition.fieldNumber + " in message " + globalMessageID); + } + fieldsPerNumber.append(fieldDefinition.fieldNumber, fieldDefinition); + } + + public FitMessageFieldDefinition findField(String fieldName) { + for (final FitMessageFieldDefinition fieldDefinition : fieldDefinitions) { + if (fieldName.equals(fieldDefinition.fieldName)) return fieldDefinition; + } + return null; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitMessageDefinitions.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitMessageDefinitions.java new file mode 100644 index 000000000..2a79275f3 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitMessageDefinitions.java @@ -0,0 +1,646 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit; + +import java.util.Arrays; +import java.util.List; + +public class FitMessageDefinitions { + public static final int MESSAGE_ID_CONNECTIVITY = 0; + public static final int MESSAGE_ID_WEATHER_ALERT = 5; + public static final int MESSAGE_ID_WEATHER_CONDITIONS = 6; + public static final int MESSAGE_ID_DEVICE_SETTINGS = 7; + + public static final String FIT_MESSAGE_NAME_FILE_ID = "file_id"; + public static final String FIT_MESSAGE_NAME_CAPABILITIES = "capabilities"; + public static final String FIT_MESSAGE_NAME_DEVICE_SETTINGS = "device_settings"; + public static final String FIT_MESSAGE_NAME_USER_PROFILE = "user_profile"; + public static final String FIT_MESSAGE_NAME_EVENT = "event"; + public static final String FIT_MESSAGE_NAME_DEVICE_INFO = "device_info"; + public static final String FIT_MESSAGE_NAME_DEBUG = "debug"; + public static final String FIT_MESSAGE_NAME_SOFTWARE = "software"; + public static final String FIT_MESSAGE_NAME_FILE_CAPABILITIES = "file_capabilities"; + public static final String FIT_MESSAGE_NAME_FILE_CREATOR = "file_creator"; + public static final String FIT_MESSAGE_NAME_MONITORING = "monitoring"; + public static final String FIT_MESSAGE_NAME_MONITORING_INFO = "monitoring_info"; + public static final String FIT_MESSAGE_NAME_CONNECTIVITY = "connectivity"; + public static final String FIT_MESSAGE_NAME_WEATHER_CONDITIONS = "weather_conditions"; + public static final String FIT_MESSAGE_NAME_WEATHER_ALERT = "weather_alert"; + public static final String FIT_MESSAGE_NAME_FILE_DESCRIPTION = "file_description"; + public static final String FIT_MESSAGE_NAME_OHR_SETTINGS = "ohr_settings"; + public static final String FIT_MESSAGE_NAME_EXD_SCREEN_CONFIGURATION = "exd_screen_configuration"; + public static final String FIT_MESSAGE_NAME_EXD_DATA_FIELD_CONFIGURATION = "exd_data_field_configuration"; + public static final String FIT_MESSAGE_NAME_EXD_DATA_CONCEPT_CONFIGURATION = "exd_data_concept_configuration"; + public static final String FIT_MESSAGE_NAME_MONITORING_HR_DATA = "monitoring_hr_data"; + public static final String FIT_MESSAGE_NAME_ALARM_SETTINGS = "alarm_settings"; + public static final String FIT_MESSAGE_NAME_STRESS_LEVEL = "stress_level"; + public static final String FIT_MESSAGE_NAME_MANUAL_STRESS_LEVEL = "manual_stress_level"; + public static final String FIT_MESSAGE_NAME_MAX_MET_DATA = "max_met_data"; + public static final String FIT_MESSAGE_NAME_WHR_DIAG = "whr_diag"; + public static final String FIT_MESSAGE_NAME_METRICS_INFO = "metrics_info"; + public static final String FIT_MESSAGE_NAME_PAGES_MAP = "pages_map"; + public static final String FIT_MESSAGE_NAME_NEURAL_NETWORK_INFO = "neural_network_info"; + public static final String FIT_MESSAGE_NAME_NEURAL_NETWORK_DATA = "neural_network_data"; + public static final String FIT_MESSAGE_NAME_SLEEP_LEVEL = "sleep_level"; + public static final String FIT_MESSAGE_NAME_END_OF_FILE = "end_of_file"; + + public static final int FIT_MESSAGE_NUMBER_FILE_ID = 0; + public static final int FIT_MESSAGE_NUMBER_CAPABILITIES = 1; + public static final int FIT_MESSAGE_NUMBER_DEVICE_SETTINGS = 2; + public static final int FIT_MESSAGE_NUMBER_USER_PROFILE = 3; + public static final int FIT_MESSAGE_NUMBER_EVENT = 21; + public static final int FIT_MESSAGE_NUMBER_DEVICE_INFO = 23; + public static final int FIT_MESSAGE_NUMBER_DEBUG = 24; + public static final int FIT_MESSAGE_NUMBER_SOFTWARE = 35; + public static final int FIT_MESSAGE_NUMBER_FILE_CAPABILITIES = 37; + public static final int FIT_MESSAGE_NUMBER_FILE_CREATOR = 49; + public static final int FIT_MESSAGE_NUMBER_MONITORING = 55; + public static final int FIT_MESSAGE_NUMBER_MONITORING_INFO = 103; + public static final int FIT_MESSAGE_NUMBER_CONNECTIVITY = 127; + public static final int FIT_MESSAGE_NUMBER_WEATHER_CONDITIONS = 128; + public static final int FIT_MESSAGE_NUMBER_WEATHER_ALERT = 129; + public static final int FIT_MESSAGE_NUMBER_FILE_DESCRIPTION = 138; + public static final int FIT_MESSAGE_NUMBER_OHR_SETTINGS = 188; + public static final int FIT_MESSAGE_NUMBER_EXD_SCREEN_CONFIGURATION = 200; + public static final int FIT_MESSAGE_NUMBER_EXD_DATA_FIELD_CONFIGURATION = 201; + public static final int FIT_MESSAGE_NUMBER_EXD_DATA_CONCEPT_CONFIGURATION = 202; + public static final int FIT_MESSAGE_NUMBER_MONITORING_HR_DATA = 211; + public static final int FIT_MESSAGE_NUMBER_ALARM_SETTINGS = 222; + public static final int FIT_MESSAGE_NUMBER_STRESS_LEVEL = 227; + public static final int FIT_MESSAGE_NUMBER_MANUAL_STRESS_LEVEL = 228; + public static final int FIT_MESSAGE_NUMBER_MAX_MET_DATA = 229; + public static final int FIT_MESSAGE_NUMBER_WHR_DIAG = 233; + public static final int FIT_MESSAGE_NUMBER_METRICS_INFO = 241; + public static final int FIT_MESSAGE_NUMBER_PAGES_MAP = 254; + public static final int FIT_MESSAGE_NUMBER_NEURAL_NETWORK_INFO = 273; + public static final int FIT_MESSAGE_NUMBER_NEURAL_NETWORK_DATA = 274; + public static final int FIT_MESSAGE_NUMBER_SLEEP_LEVEL = 275; + public static final int FIT_MESSAGE_NUMBER_END_OF_FILE = 276; + + public static final FitMessageDefinition DEFINITION_FILE_ID = new FitMessageDefinition(FIT_MESSAGE_NAME_FILE_ID, FIT_MESSAGE_NUMBER_FILE_ID, -1, + new FitMessageFieldDefinition("type", 0, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("manufacturer", 1, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("product", 2, 2, FitFieldBaseType.UINT16, null), + new FitMessageFieldDefinition("serial_number", 3, 4, FitFieldBaseType.UINT32Z, null), + new FitMessageFieldDefinition("time_created", 4, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("number", 5, 2, FitFieldBaseType.UINT16, null), + new FitMessageFieldDefinition("manufacturer_partner", 6, 2, FitFieldBaseType.UINT16, null), + new FitMessageFieldDefinition("product_name", 8, 20, FitFieldBaseType.STRING, null) + ); + + public static final FitMessageDefinition DEFINITION_CAPABILITIES = new FitMessageDefinition(FIT_MESSAGE_NAME_CAPABILITIES, FIT_MESSAGE_NUMBER_CAPABILITIES, -1, + new FitMessageFieldDefinition("languages", 0, 1, FitFieldBaseType.UINT8Z, null), + new FitMessageFieldDefinition("sports", 1, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("workouts_supported", 21, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("activity_profile_supported", 22, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("connectivity_supported", 23, 4, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("wifi_supported", 24, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("segments_supported", 25, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("audio_prompts_supported", 26, 1, FitFieldBaseType.ENUM, null) + ); + + public static final FitMessageDefinition DEFINITION_DEVICE_SETTINGS = new FitMessageDefinition(FIT_MESSAGE_NAME_DEVICE_SETTINGS, FIT_MESSAGE_NUMBER_DEVICE_SETTINGS, MESSAGE_ID_DEVICE_SETTINGS, + new FitMessageFieldDefinition("active_time_zone", 0, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("utc_offset", 1, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("time_offset", 2, 4, FitFieldBaseType.UINT32, 1, 0, "s", null), + new FitMessageFieldDefinition("time_daylight_savings", 3, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("time_mode", 4, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("time_zone_offset", 5, 1, FitFieldBaseType.SINT8, 1, 0, "hr", null), + new FitMessageFieldDefinition("alarm_time", 8, 2, FitFieldBaseType.UINT16, null), + new FitMessageFieldDefinition("alarm_mode", 9, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("key_tones_enabled", 10, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("message_tones_enabled", 11, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("backlight_mode", 12, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("backlight_timeout", 13, 1, FitFieldBaseType.UINT8, 1, 0, "s", null), + new FitMessageFieldDefinition("backlight_brightness", 14, 1, FitFieldBaseType.UINT8, 1, 0, "%", null), + new FitMessageFieldDefinition("display_contrast", 15, 1, FitFieldBaseType.UINT8, 1, 0, "%", null), + new FitMessageFieldDefinition("computer_beacon", 16, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("computer_pairing", 17, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("fitness_equipment_pairing", 18, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("bezel_sensitivity", 19, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("gps_enabled", 21, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("weight_scale_enabled", 22, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("map_orientation", 23, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("map_show", 24, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("map_show_locations", 25, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("time_zone", 26, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("auto_shutdown", 27, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("alarm_tone", 28, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("data_storage", 29, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("map_auto_zoom", 30, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("map_guidance", 31, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("current_map_profile", 32, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("current_routing_profile", 33, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("display_mode", 34, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("first_day_of_week", 35, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("activity_tracker_enabled", 36, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("sleep_enabled", 37, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("wifi_auto_upload_enabled", 38, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("clock_time", 39, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("pages_enabled", 40, 2, FitFieldBaseType.UINT16, null), + new FitMessageFieldDefinition("recovery_advisor_enabled", 41, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("auto_max_hr_enabled", 42, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("clock_profile_color_enabled", 43, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("clock_background_inverted", 44, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("auto_goal_enabled", 45, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("move_alert_enabled", 46, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("date_mode", 47, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("data_recording_interval", 48, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("data_recording_value", 49, 2, FitFieldBaseType.UINT16, null), + new FitMessageFieldDefinition("vivohub_settings", 50, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("display_steps_goal_enabled", 51, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("course_navigation_enabled", 52, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("course_off_course_warnings_enabled", 53, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("segment_navigation_enabled", 54, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("display_orientation", 55, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("mounting_side", 56, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("default_page", 57, 2, FitFieldBaseType.UINT16, null), + new FitMessageFieldDefinition("autosync_min_steps", 58, 2, FitFieldBaseType.UINT16, 1, 0, "steps", null), + new FitMessageFieldDefinition("autosync_min_time", 59, 2, FitFieldBaseType.UINT16, 1, 0, "minutes", null), + new FitMessageFieldDefinition("smart_sleep_window", 60, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("gesture_detection_mode", 61, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("glonass_enabled", 62, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("display_pace", 63, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("display_activity_tracker_enabled", 64, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("phone_notification_enabled", 65, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("phone_notification_tone", 66, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("phone_notification_default_filter", 67, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("phone_notification_activity_filter", 68, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("phone_notification_activity_tone", 69, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("user_notices_enabled", 70, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("lap_key_enabled", 71, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("features", 72, 1, FitFieldBaseType.UINT8Z, null), + new FitMessageFieldDefinition("features_mask", 73, 1, FitFieldBaseType.UINT8Z, null), + new FitMessageFieldDefinition("course_points_enabled", 74, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("course_segments_enabled", 75, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("map_show_track", 76, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("map_track_color", 77, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("next_dst_change", 78, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("dst_change_value", 79, 1, FitFieldBaseType.SINT8, 1, 0, "hours", null), + new FitMessageFieldDefinition("lactate_threshold_autodetect_enabled", 80, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("backlight_keys", 81, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("backlight_alerts", 82, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("backlight_gesture", 83, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("bluetooth_connection_alerts_enabled", 84, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("ftp_auto_calc_enabled", 85, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("ble_auto_upload_enabled", 86, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("sleep_do_not_disturb_enabled", 87, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("backlight_smart_notifications", 88, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("auto_sync_frequency", 89, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("auto_activity_detect", 90, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("phone_notification_filters", 91, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("alarm_days", 92, 1, FitFieldBaseType.BYTE, null), + new FitMessageFieldDefinition("auto_update_app_enabled", 93, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("number_of_screens", 94, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("smart_notification_display_orientation", 95, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("auto_lock_enabled", 96, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("grouptrack_activity_type", 97, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("wifi_enabled", 98, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("smart_notification_enabled", 99, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("beeper_enabled", 100, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("goal_notification", 101, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("product_category", 102, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("touch_sensitivity", 103, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("power_controls_items", 104, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("selected_watchface_index", 105, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("livetrack_message_notification_enabled", 106, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("alert_tones_app_only", 107, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("auto_detect_max_hr", 108, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("perf_cond_ntfctn_enabled", 109, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("new_vo2_ntfctn_enabled", 110, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("training_effect_ntfctn_enabled", 111, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("recovery_time_ntfctn_enabled", 112, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("auto_activity_start_enabled", 113, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("move_bar_enabled", 114, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("vibration_intensity", 115, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("lock_on_road", 116, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("map_detail", 117, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("screen_timeout", 119, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("display_theme", 120, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("popularity_routing_enabled", 121, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("glance_mode_layout", 122, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("user_text", 123, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("backlight_brightness_current_activity", 124, 1, FitFieldBaseType.UINT8, 1, 0, "%", null), + new FitMessageFieldDefinition("backlight_timeout_current_activity", 125, 1, FitFieldBaseType.UINT8, 1, 0, "s", null), + new FitMessageFieldDefinition("backlight_keys_current_activity", 126, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("backlight_alerts_current_activity", 127, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("backlight_gesture_current_activity", 128, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("marine_chart_mode", 129, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("spot_soundings", 130, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("light_sectors", 131, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("marine_symbol_set", 132, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("auto_update_software_enabled", 133, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("tap_interface", 134, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("auto_lock_mode", 135, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("simplified_backlight_timeout", 136, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("draw_segments", 137, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("hourly_alert", 138, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("turn_guidance_popup", 139, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("stress_alert_enabled", 140, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("spo2_mode", 141, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("low_spo2_threshold", 142, 1, FitFieldBaseType.UINT8, 1, 0, "percent", null), + new FitMessageFieldDefinition("sedentary_hr_alert_threshold", 143, 1, FitFieldBaseType.UINT8, 1, 0, "bpm", null), + new FitMessageFieldDefinition("activity_physio_true_up_enabled", 144, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("smart_notification_timeout", 145, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("sideswipe_enabled", 146, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("sideswipe_direction_inverted", 147, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("draw_contour_lines", 148, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("sedentary_hr_alert_state", 149, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("autosync_max_steps", 150, 2, FitFieldBaseType.UINT16, null), + new FitMessageFieldDefinition("low_spo2_alert_enabled", 151, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("cda_auto_calc_enabled", 152, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("hydration_system_units", 153, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("hydration_containers", 154, 2, FitFieldBaseType.UINT16, null), + new FitMessageFieldDefinition("hydration_alert_enabled", 155, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("hydration_alert_frequency", 156, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("hydration_containers_units", 157, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("hydration_auto_goal_enabled", 158, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("user_phone_verified", 159, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("primary_tracker_enabled", 160, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("phone_notification_default_privacy", 161, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("phone_notification_activity_privacy", 162, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("abnormal_low_hr_alert_state", 163, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("abnormal_low_hr_alert_threshold", 164, 1, FitFieldBaseType.UINT8, 1, 0, "bpm", null) + ); + + public static final FitMessageDefinition DEFINITION_USER_PROFILE = new FitMessageDefinition(FIT_MESSAGE_NAME_USER_PROFILE, FIT_MESSAGE_NUMBER_USER_PROFILE, -1, + new FitMessageFieldDefinition("friendly_name", 0, 16, FitFieldBaseType.STRING, null), + new FitMessageFieldDefinition("gender", 1, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("age", 2, 1, FitFieldBaseType.UINT8, 1, 0, "years", null), + new FitMessageFieldDefinition("height", 3, 1, FitFieldBaseType.UINT8, 1, 0, "cm", null), + new FitMessageFieldDefinition("weight", 4, 2, FitFieldBaseType.UINT16, 10, 0, "kg", null), + new FitMessageFieldDefinition("language", 5, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("elev_setting", 6, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("weight_setting", 7, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("resting_heart_rate", 8, 1, FitFieldBaseType.UINT8, 1, 0, "bpm", null), + new FitMessageFieldDefinition("default_max_running_heart_rate", 9, 1, FitFieldBaseType.UINT8, 1, 0, "bpm", null), + new FitMessageFieldDefinition("default_max_biking_heart_rate", 10, 1, FitFieldBaseType.UINT8, 1, 0, "bpm", null), + new FitMessageFieldDefinition("default_max_heart_rate", 11, 1, FitFieldBaseType.UINT8, 1, 0, "bpm", null), + new FitMessageFieldDefinition("hr_setting", 12, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("speed_setting", 13, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("dist_setting", 14, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("power_setting", 16, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("activity_class", 17, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("position_setting", 18, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("rmr", 19, 2, FitFieldBaseType.UINT16, 1, 0, "kcal/d", null), + new FitMessageFieldDefinition("active_time", 20, 1, FitFieldBaseType.UINT8, 1, 0, "min", null), + new FitMessageFieldDefinition("temperature_setting", 21, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("local_id", 22, 2, FitFieldBaseType.UINT16, null), + new FitMessageFieldDefinition("global_id", 23, 6, FitFieldBaseType.BYTE, null), + new FitMessageFieldDefinition("birth_year", 24, 1, FitFieldBaseType.UINT8, 1, 1900, "", null), + new FitMessageFieldDefinition("avg_cycle_length", 25, 2, FitFieldBaseType.UINT16, 10000, 0, "m", null), + new FitMessageFieldDefinition("pressure_setting", 26, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("handedness", 27, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("wake_time", 28, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("sleep_time", 29, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("height_setting", 30, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("user_running_step_length", 31, 2, FitFieldBaseType.UINT16, 1, 0, "mm", null), + new FitMessageFieldDefinition("user_walking_step_length", 32, 2, FitFieldBaseType.UINT16, 1, 0, "mm", null), + new FitMessageFieldDefinition("firstbeat_monthly_load", 33, 2, FitFieldBaseType.UINT16, null), + new FitMessageFieldDefinition("firstbeat_recovery_time", 34, 2, FitFieldBaseType.UINT16, null), + new FitMessageFieldDefinition("firstbeat_recovery_time_start", 35, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("firstbeat_max_stress_score", 36, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("firstbeat_running_lt_kmh", 37, 2, FitFieldBaseType.UINT16, 10, 0, "km/h", null), + new FitMessageFieldDefinition("firstbeat_cycling_lt_watts", 38, 2, FitFieldBaseType.UINT16, null), + new FitMessageFieldDefinition("firstbeat_running_maxMET", 39, 4, FitFieldBaseType.FLOAT32, null), + new FitMessageFieldDefinition("firstbeat_cycling_maxMET", 40, 4, FitFieldBaseType.FLOAT32, null), + new FitMessageFieldDefinition("firstbeat_running_lt_timestamp", 41, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("firstbeat_cycling_lt_timestamp", 42, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("resting_hr_auto_update_enabled", 43, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("birth_day", 44, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("birth_month", 45, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("avatar", 46, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("depth_setting", 47, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("dive_count", 49, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("phone_number", 50, 20, FitFieldBaseType.STRING, null), + new FitMessageFieldDefinition("keep_user_name_private", 51, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("active_minutes_calc_method", 52, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("active_minutes_moderate_zone", 53, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("active_minutes_vigorous_zone", 54, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("swim_skill_level", 55, 1, FitFieldBaseType.UINT8, null) + ); + + public static final FitMessageDefinition DEFINITION_EVENT = new FitMessageDefinition(FIT_MESSAGE_NAME_EVENT, FIT_MESSAGE_NUMBER_EVENT, -1, + new FitMessageFieldDefinition("timestamp", 253, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("event", 0, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("event_type", 1, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("data16", 2, 2, FitFieldBaseType.UINT16, null), + new FitMessageFieldDefinition("data", 3, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("event_group", 4, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("device_index", 13, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("activity_type", 14, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("start_timestamp", 15, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("activity_subtype", 16, 1, FitFieldBaseType.ENUM, null) + ); + + public static final FitMessageDefinition DEFINITION_DEVICE_INFO = new FitMessageDefinition(FIT_MESSAGE_NAME_DEVICE_INFO, FIT_MESSAGE_NUMBER_DEVICE_INFO, -1, + new FitMessageFieldDefinition("timestamp", 253, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("device_index", 0, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("device_type", 1, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("manufacturer", 2, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("serial_number", 3, 4, FitFieldBaseType.UINT32Z, null), + new FitMessageFieldDefinition("product", 4, 2, FitFieldBaseType.UINT16, null), + new FitMessageFieldDefinition("software_version", 5, 2, FitFieldBaseType.UINT16, null), + new FitMessageFieldDefinition("hardware_version", 6, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("cum_operating_time", 7, 4, FitFieldBaseType.UINT32, 1, 0, "s", null), + new FitMessageFieldDefinition("cum_training_time", 8, 4, FitFieldBaseType.UINT32, 1, 0, "s", null), + new FitMessageFieldDefinition("reception", 9, 4, FitFieldBaseType.UINT8, 1, 0, "%", null), + new FitMessageFieldDefinition("battery_voltage", 10, 2, FitFieldBaseType.UINT16, 256, 0, "V", null), + new FitMessageFieldDefinition("battery_status", 11, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("rx_pass_count", 15, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("rx_fail_count", 16, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("software_version_string", 17, 20, FitFieldBaseType.STRING, null), + new FitMessageFieldDefinition("sensor_position", 18, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("descriptor", 19, 20, FitFieldBaseType.STRING, null), + new FitMessageFieldDefinition("ant_transmission_type", 20, 1, FitFieldBaseType.UINT8Z, null), + new FitMessageFieldDefinition("ant_device_number", 21, 2, FitFieldBaseType.UINT16Z, null), + new FitMessageFieldDefinition("ant_network", 22, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("source_type", 25, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("product_name", 27, 20, FitFieldBaseType.STRING, null) + ); + + public static final FitMessageDefinition DEFINITION_DEBUG = new FitMessageDefinition(FIT_MESSAGE_NAME_DEBUG, FIT_MESSAGE_NUMBER_DEBUG, -1, + new FitMessageFieldDefinition("timestamp", 253, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("id", 0, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("string", 1, 20, FitFieldBaseType.STRING, null), + new FitMessageFieldDefinition("data", 2, 20, FitFieldBaseType.BYTE, null), + new FitMessageFieldDefinition("time256", 3, 20, FitFieldBaseType.UINT8, 256, 0, "s", null), + new FitMessageFieldDefinition("fractional_timestamp", 4, 2, FitFieldBaseType.UINT16, 32768.0, 0, "s", null) + ); + + public static final FitMessageDefinition DEFINITION_SOFTWARE = new FitMessageDefinition(FIT_MESSAGE_NAME_SOFTWARE, FIT_MESSAGE_NUMBER_SOFTWARE, -1, + new FitMessageFieldDefinition("message_index", 254, 2, FitFieldBaseType.UINT16, null), + new FitMessageFieldDefinition("version", 3, 2, FitFieldBaseType.UINT16, null), + new FitMessageFieldDefinition("part_number", 5, 20, FitFieldBaseType.STRING, null), + new FitMessageFieldDefinition("version_string", 6, 20, FitFieldBaseType.STRING, null) + ); + + public static final FitMessageDefinition DEFINITION_FILE_CAPABILITIES = new FitMessageDefinition(FIT_MESSAGE_NAME_FILE_CAPABILITIES, FIT_MESSAGE_NUMBER_FILE_CAPABILITIES, -1, + new FitMessageFieldDefinition("type", 0, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("flags", 1, 1, FitFieldBaseType.UINT8Z, null), + new FitMessageFieldDefinition("directory", 2, 16, FitFieldBaseType.STRING, null), + new FitMessageFieldDefinition("max_count", 3, 2, FitFieldBaseType.UINT16, null), + new FitMessageFieldDefinition("max_size", 4, 4, FitFieldBaseType.UINT32, null) + ); + + public static final FitMessageDefinition DEFINITION_FILE_CREATOR = new FitMessageDefinition(FIT_MESSAGE_NAME_FILE_CREATOR, FIT_MESSAGE_NUMBER_FILE_CREATOR, -1, + new FitMessageFieldDefinition("software_version", 0, 2, FitFieldBaseType.UINT16, null), + new FitMessageFieldDefinition("hardware_version", 1, 2, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("creator_name", 2, 2, FitFieldBaseType.STRING, null) + ); + + public static final FitMessageDefinition DEFINITION_MONITORING = new FitMessageDefinition(FIT_MESSAGE_NAME_MONITORING, FIT_MESSAGE_NUMBER_MONITORING, -1, + new FitMessageFieldDefinition("timestamp", 253, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("device_index", 0, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("calories", 1, 2, FitFieldBaseType.UINT16, null), + new FitMessageFieldDefinition("distance", 2, 4, FitFieldBaseType.UINT32, 100, 0, "m", null), + // TODO: Scale depends on activity type + new FitMessageFieldDefinition("cycles", 3, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("active_time", 4, 1, FitFieldBaseType.UINT32, 1000, 0, "s", null), + new FitMessageFieldDefinition("activity_type", 5, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("activity_subtype", 6, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("activity_level", 7, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("distance_16", 8, 2, FitFieldBaseType.UINT16, 0.01, 0, "m", null), + new FitMessageFieldDefinition("cycles_16", 9, 2, FitFieldBaseType.UINT16, 0.5, 0, "cycles", null), + new FitMessageFieldDefinition("active_time_16", 10, 2, FitFieldBaseType.UINT16, 1, 0, "s", null), + new FitMessageFieldDefinition("local_timestamp", 11, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("temperature", 12, 2, FitFieldBaseType.SINT16, 100, 0, "°C", null), + new FitMessageFieldDefinition("temperature_min", 14, 2, FitFieldBaseType.SINT16, 100, 0, "°C", null), + new FitMessageFieldDefinition("temperature_max", 15, 2, FitFieldBaseType.SINT16, 100, 0, "°C", null), + // TODO: Array + new FitMessageFieldDefinition("activity_time", 16, 2, FitFieldBaseType.UINT16, 1, 0, "min", null), + new FitMessageFieldDefinition("active_calories", 19, 2, FitFieldBaseType.UINT16, 1, 0, "kcal", null), + new FitMessageFieldDefinition("current_activity_type_intensity", 24, 1, FitFieldBaseType.BYTE, null), + new FitMessageFieldDefinition("timestamp_min_8", 25, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("timestamp_16", 26, 2, FitFieldBaseType.UINT16, null), + new FitMessageFieldDefinition("heart_rate", 27, 1, FitFieldBaseType.UINT8, 1, 0, "bpm", null), + new FitMessageFieldDefinition("intensity", 28, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("duration_min", 29, 2, FitFieldBaseType.UINT16, 1, 0, "min", null), + new FitMessageFieldDefinition("duration", 30, 4, FitFieldBaseType.UINT32, 1, 0, "s", null), + new FitMessageFieldDefinition("ascent", 31, 4, FitFieldBaseType.UINT32, 1000, 0, "m", null), + new FitMessageFieldDefinition("descent", 32, 4, FitFieldBaseType.UINT32, 1000, 0, "m", null), + new FitMessageFieldDefinition("moderate_activity_minutes", 33, 2, FitFieldBaseType.UINT16, 1, 0, "min", null), + new FitMessageFieldDefinition("vigorous_activity_minutes", 34, 2, FitFieldBaseType.UINT16, 1, 0, "min", null), + new FitMessageFieldDefinition("ascent_total", 35, 4, FitFieldBaseType.UINT32, 1000, 0, "m", null), + new FitMessageFieldDefinition("descent_total", 36, 4, FitFieldBaseType.UINT32, 1000, 0, "m", null), + new FitMessageFieldDefinition("moderate_activity_minutes_total", 37, 2, FitFieldBaseType.UINT16, 1, 0, "min", null), + new FitMessageFieldDefinition("vigorous_activity_minutes_total", 38, 2, FitFieldBaseType.UINT16, 1, 0, "min", null) + ); + + public static final FitMessageDefinition DEFINITION_MONITORING_INFO = new FitMessageDefinition(FIT_MESSAGE_NAME_MONITORING_INFO, FIT_MESSAGE_NUMBER_MONITORING_INFO, -1, + new FitMessageFieldDefinition("timestamp", 253, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("local_timestamp", 0, 4, FitFieldBaseType.UINT32, null), + // TODO: Arrays + new FitMessageFieldDefinition("activity_type", 1, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("cycles_to_distance", 3, 2, FitFieldBaseType.UINT16, null), + new FitMessageFieldDefinition("cycles_to_calories", 4, 2, FitFieldBaseType.UINT16, null), + + new FitMessageFieldDefinition("resting_metabolic_rate", 5, 2, FitFieldBaseType.UINT16, null), + new FitMessageFieldDefinition("cycles_goal", 7, 4, FitFieldBaseType.UINT32, 2, 0, "cycles", null), + new FitMessageFieldDefinition("monitoring_time_source", 8, 1, FitFieldBaseType.ENUM, null) + ); + + public static final FitMessageDefinition DEFINITION_CONNECTIVITY = new FitMessageDefinition(FIT_MESSAGE_NAME_CONNECTIVITY, FIT_MESSAGE_NUMBER_CONNECTIVITY, MESSAGE_ID_CONNECTIVITY, + new FitMessageFieldDefinition("bluetooth_enabled", 0, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("bluetooth_le_enabled", 1, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("ant_enabled", 2, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("name", 3, 16, FitFieldBaseType.STRING, null), + new FitMessageFieldDefinition("live_tracking_enabled", 4, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("weather_conditions_enabled", 5, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("weather_alerts_enabled", 6, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("auto_activity_upload_enabled", 7, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("course_download_enabled", 8, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("workout_download_enabled", 9, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("gps_ephemeris_download_enabled", 10, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("live_track_auto_start_enabled", 13, 1, FitFieldBaseType.ENUM, FitBool.FALSE) + ); + + public static final FitMessageDefinition DEFINITION_WEATHER_CONDITIONS = new FitMessageDefinition(FIT_MESSAGE_NAME_WEATHER_CONDITIONS, FIT_MESSAGE_NUMBER_WEATHER_CONDITIONS, MESSAGE_ID_WEATHER_CONDITIONS, + new FitMessageFieldDefinition("timestamp", 253, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("weather_report", 0, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("temperature", 1, 1, FitFieldBaseType.SINT8, 1, 0, "°C", null), + new FitMessageFieldDefinition("condition", 2, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("wind_direction", 3, 2, FitFieldBaseType.UINT16, 1, 0, "°", null), + new FitMessageFieldDefinition("wind_speed", 4, 2, FitFieldBaseType.UINT16, 1000, 0, "m/s", null), + new FitMessageFieldDefinition("precipitation_probability", 5, 1, FitFieldBaseType.UINT8, 1, 0, "%", null), + new FitMessageFieldDefinition("temperature_feels_like", 6, 1, FitFieldBaseType.SINT8, 1, 0, "°C", null), + new FitMessageFieldDefinition("relative_humidity", 7, 1, FitFieldBaseType.UINT8, 1, 0, "%", null), + new FitMessageFieldDefinition("location", 8, 16, FitFieldBaseType.STRING, null), + new FitMessageFieldDefinition("observed_at_time", 9, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("observed_location_lat", 10, 4, FitFieldBaseType.SINT32, 1, 0, "semicircles", null), + new FitMessageFieldDefinition("observed_location_long", 11, 4, FitFieldBaseType.SINT32, 1, 0, "semicircles", null), + new FitMessageFieldDefinition("day_of_week", 12, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("high_temperature", 13, 1, FitFieldBaseType.SINT8, 1, 0, "°C", null), + new FitMessageFieldDefinition("low_temperature", 14, 1, FitFieldBaseType.SINT8, 1, 0, "°C", null) + ); + + public static final FitMessageDefinition DEFINITION_WEATHER_ALERT = new FitMessageDefinition(FIT_MESSAGE_NAME_WEATHER_ALERT, FIT_MESSAGE_NUMBER_WEATHER_ALERT, MESSAGE_ID_WEATHER_ALERT, + new FitMessageFieldDefinition("timestamp", 253, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("report_id", 0, 10, FitFieldBaseType.STRING, null), + new FitMessageFieldDefinition("issue_time", 1, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("expire_time", 2, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("severity", 3, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("type", 4, 1, FitFieldBaseType.ENUM, null) + ); + + public static final FitMessageDefinition DEFINITION_FILE_DESCRIPTION = new FitMessageDefinition(FIT_MESSAGE_NAME_FILE_DESCRIPTION, FIT_MESSAGE_NUMBER_FILE_DESCRIPTION, -1, + new FitMessageFieldDefinition("message_index", 254, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("manufacturer", 0, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("directory", 1, 16, FitFieldBaseType.STRING, null), + new FitMessageFieldDefinition("name", 2, 20, FitFieldBaseType.STRING, null), + new FitMessageFieldDefinition("flags", 3, 1, FitFieldBaseType.UINT8Z, null), + new FitMessageFieldDefinition("purpose", 4, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("garmin_file_purpose", 5, 1, FitFieldBaseType.UINT8, null) + ); + + public static final FitMessageDefinition DEFINITION_OHR_SETTINGS = new FitMessageDefinition(FIT_MESSAGE_NAME_OHR_SETTINGS, FIT_MESSAGE_NUMBER_OHR_SETTINGS, -1, + new FitMessageFieldDefinition("timestamp", 253, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("enabled", 0, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("sample_rate", 1, 2, FitFieldBaseType.UINT16, null), + new FitMessageFieldDefinition("transmit_hr_enabled", 2, 1, FitFieldBaseType.ENUM, FitBool.FALSE) + ); + + public static final FitMessageDefinition DEFINITION_EXD_SCREEN_CONFIGURATION = new FitMessageDefinition(FIT_MESSAGE_NAME_EXD_SCREEN_CONFIGURATION, FIT_MESSAGE_NUMBER_EXD_SCREEN_CONFIGURATION, -1, + new FitMessageFieldDefinition("screen_index", 0, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("field_count", 1, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("layout", 2, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("screen_enabled", 3, 1, FitFieldBaseType.ENUM, null) + ); + + public static final FitMessageDefinition DEFINITION_EXD_DATA_FIELD_CONFIGURATION = new FitMessageDefinition(FIT_MESSAGE_NAME_EXD_DATA_FIELD_CONFIGURATION, FIT_MESSAGE_NUMBER_EXD_DATA_FIELD_CONFIGURATION, -1, + new FitMessageFieldDefinition("screen_index", 0, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("concept_field", 1, 1, FitFieldBaseType.BYTE, null), + new FitMessageFieldDefinition("field_id", 2, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("concept_count", 3, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("display_type", 4, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("title", 5, 32, FitFieldBaseType.STRING, null) + ); + + public static final FitMessageDefinition DEFINITION_EXD_DATA_CONCEPT_CONFIGURATION = new FitMessageDefinition(FIT_MESSAGE_NAME_EXD_DATA_CONCEPT_CONFIGURATION, FIT_MESSAGE_NUMBER_EXD_DATA_CONCEPT_CONFIGURATION, -1, + new FitMessageFieldDefinition("screen_index", 0, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("concept_field", 1, 1, FitFieldBaseType.BYTE, null), + new FitMessageFieldDefinition("field_id", 2, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("concept_index", 3, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("data_page", 4, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("concept_key", 5, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("scaling", 6, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("offset", 7, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("data_units", 8, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("qualifier", 9, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("descriptor", 10, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("is_signed", 11, 1, FitFieldBaseType.ENUM, FitBool.FALSE) + ); + + public static final FitMessageDefinition DEFINITION_MONITORING_HR_DATA = new FitMessageDefinition(FIT_MESSAGE_NAME_MONITORING_HR_DATA, FIT_MESSAGE_NUMBER_MONITORING_HR_DATA, -1, + new FitMessageFieldDefinition("timestamp", 253, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("resting_heart_rate", 0, 1, FitFieldBaseType.UINT8, 1, 0, "bpm", null), + new FitMessageFieldDefinition("current_day_resting_heart_rate", 1, 1, FitFieldBaseType.UINT8, 1, 0, "bpm", null) + ); + + public static final FitMessageDefinition DEFINITION_ALARM_SETTINGS = new FitMessageDefinition(FIT_MESSAGE_NAME_ALARM_SETTINGS, FIT_MESSAGE_NUMBER_ALARM_SETTINGS, -1, + new FitMessageFieldDefinition("message_index", 254, 2, FitFieldBaseType.UINT16, null), + new FitMessageFieldDefinition("time", 0, 2, FitFieldBaseType.UINT16, null), + new FitMessageFieldDefinition("days", 1, 1, FitFieldBaseType.BYTE, null), + new FitMessageFieldDefinition("enabled", 2, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("sound", 3, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("backlight", 4, 1, FitFieldBaseType.ENUM, FitBool.FALSE), + new FitMessageFieldDefinition("id", 5, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("alarm_mesg", 6, 20, FitFieldBaseType.STRING, null), + new FitMessageFieldDefinition("snooze_count", 7, 1, FitFieldBaseType.UINT8, null) + ); + + public static final FitMessageDefinition DEFINITION_STRESS_LEVEL = new FitMessageDefinition(FIT_MESSAGE_NAME_STRESS_LEVEL, FIT_MESSAGE_NUMBER_STRESS_LEVEL, -1, + new FitMessageFieldDefinition("stress_level_value", 0, 2, FitFieldBaseType.SINT16, null), + new FitMessageFieldDefinition("stress_level_time", 1, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("average_stress_intensity", 2, 1, FitFieldBaseType.SINT8, null) + ); + + public static final FitMessageDefinition DEFINITION_MANUAL_STRESS_LEVEL = new FitMessageDefinition(FIT_MESSAGE_NAME_MANUAL_STRESS_LEVEL, FIT_MESSAGE_NUMBER_MANUAL_STRESS_LEVEL, -1, + new FitMessageFieldDefinition("stress_level_value", 0, 2, FitFieldBaseType.SINT16, null), + new FitMessageFieldDefinition("stress_level_time", 1, 4, FitFieldBaseType.UINT32, null) + ); + + public static final FitMessageDefinition DEFINITION_MAX_MET_DATA = new FitMessageDefinition(FIT_MESSAGE_NAME_MAX_MET_DATA, FIT_MESSAGE_NUMBER_MAX_MET_DATA, -1, + new FitMessageFieldDefinition("update_time", 0, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("max_met", 1, 4, FitFieldBaseType.SINT32, null), + new FitMessageFieldDefinition("vo2_max", 2, 2, FitFieldBaseType.UINT16, 10, 0, "mL/kg/min", null), + new FitMessageFieldDefinition("fitness_age", 3, 1, FitFieldBaseType.SINT8, null), + new FitMessageFieldDefinition("fitness_age_desc", 4, 1, FitFieldBaseType.SINT8, null), + new FitMessageFieldDefinition("sport", 5, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("sub_sport", 6, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("analyzer_method", 7, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("max_met_category", 8, 1, FitFieldBaseType.ENUM, null), + new FitMessageFieldDefinition("calibrated_data", 9, 1, FitFieldBaseType.ENUM, null) + ); + + public static final FitMessageDefinition DEFINITION_WHR_DIAG = new FitMessageDefinition(FIT_MESSAGE_NAME_WHR_DIAG, FIT_MESSAGE_NUMBER_WHR_DIAG, -1, + new FitMessageFieldDefinition("timestamp", 253, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("fractional_timestamp", 1, 2, FitFieldBaseType.UINT16, 32768.0, 0, "s", null), + new FitMessageFieldDefinition("page_data", 2, 1, FitFieldBaseType.BYTE, null) + ); + + public static final FitMessageDefinition DEFINITION_METRICS_INFO = new FitMessageDefinition(FIT_MESSAGE_NAME_METRICS_INFO, FIT_MESSAGE_NUMBER_METRICS_INFO, -1, + new FitMessageFieldDefinition("timestamp", 253, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("local_timestamp", 0, 4, FitFieldBaseType.UINT32, null) + ); + + public static final FitMessageDefinition DEFINITION_PAGES_MAP = new FitMessageDefinition(FIT_MESSAGE_NAME_PAGES_MAP, FIT_MESSAGE_NUMBER_PAGES_MAP, -1, + new FitMessageFieldDefinition("message_index", 254, 2, FitFieldBaseType.UINT16, null), + new FitMessageFieldDefinition("map", 0, 10, FitFieldBaseType.BYTE, null), + new FitMessageFieldDefinition("default_to_last", 1, 1, FitFieldBaseType.ENUM, FitBool.FALSE) + ); + + public static final FitMessageDefinition DEFINITION_NEURAL_NETWORK_INFO = new FitMessageDefinition(FIT_MESSAGE_NAME_NEURAL_NETWORK_INFO, FIT_MESSAGE_NUMBER_NEURAL_NETWORK_INFO, -1, + new FitMessageFieldDefinition("timestamp", 253, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("network_version", 0, 1, FitFieldBaseType.UINT8, null), + new FitMessageFieldDefinition("implicit_message_duration", 1, 2, FitFieldBaseType.UINT16, 1, 0, "s", null), + new FitMessageFieldDefinition("local_timestamp", 2, 4, FitFieldBaseType.UINT32, null) + ); + + public static final FitMessageDefinition DEFINITION_NEURAL_NETWORK_DATA = new FitMessageDefinition(FIT_MESSAGE_NAME_NEURAL_NETWORK_DATA, FIT_MESSAGE_NUMBER_NEURAL_NETWORK_DATA, -1, + new FitMessageFieldDefinition("network_data", 0, 20, FitFieldBaseType.BYTE, null) + ); + + public static final FitMessageDefinition DEFINITION_SLEEP_LEVEL = new FitMessageDefinition(FIT_MESSAGE_NAME_SLEEP_LEVEL, FIT_MESSAGE_NUMBER_SLEEP_LEVEL, -1, + new FitMessageFieldDefinition("timestamp", 253, 4, FitFieldBaseType.UINT32, null), + new FitMessageFieldDefinition("sleep_level", 0, 1, FitFieldBaseType.ENUM, null) + ); + + public static final FitMessageDefinition DEFINITION_END_OF_FILE = new FitMessageDefinition(FIT_MESSAGE_NAME_END_OF_FILE, FIT_MESSAGE_NUMBER_END_OF_FILE, -1, + new FitMessageFieldDefinition("timestamp", 253, 4, FitFieldBaseType.UINT32, null) + ); + + + public static final List ALL_DEFINITIONS = Arrays.asList( + DEFINITION_FILE_ID, + DEFINITION_CAPABILITIES, + DEFINITION_DEVICE_SETTINGS, + DEFINITION_USER_PROFILE, + DEFINITION_EVENT, + DEFINITION_DEVICE_INFO, + DEFINITION_DEBUG, + DEFINITION_SOFTWARE, + DEFINITION_FILE_CAPABILITIES, + DEFINITION_FILE_CREATOR, + DEFINITION_MONITORING, + DEFINITION_MONITORING_INFO, + DEFINITION_CONNECTIVITY, + DEFINITION_WEATHER_CONDITIONS, + DEFINITION_WEATHER_ALERT, + DEFINITION_FILE_DESCRIPTION, + DEFINITION_EXD_SCREEN_CONFIGURATION, + DEFINITION_EXD_DATA_FIELD_CONFIGURATION, + DEFINITION_EXD_DATA_CONCEPT_CONFIGURATION, + DEFINITION_OHR_SETTINGS, + DEFINITION_MONITORING_HR_DATA, + DEFINITION_ALARM_SETTINGS, + DEFINITION_STRESS_LEVEL, + DEFINITION_MANUAL_STRESS_LEVEL, + DEFINITION_MAX_MET_DATA, + DEFINITION_WHR_DIAG, + DEFINITION_METRICS_INFO, + DEFINITION_PAGES_MAP, + DEFINITION_NEURAL_NETWORK_INFO, + DEFINITION_NEURAL_NETWORK_DATA, + DEFINITION_SLEEP_LEVEL, + DEFINITION_END_OF_FILE + ); +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitMessageFieldDefinition.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitMessageFieldDefinition.java new file mode 100644 index 000000000..9e278ee71 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitMessageFieldDefinition.java @@ -0,0 +1,35 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit; + +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.MessageWriter; + +public class FitMessageFieldDefinition { + public final String fieldName; + public final int fieldNumber; + public final int fieldSize; + public final FitFieldBaseType fieldType; + public final double scale; + public final double offset; + public final String units; + public final Object defaultValue; + + public FitMessageFieldDefinition(String fieldName, int fieldNumber, int fieldSize, FitFieldBaseType fieldType, Object defaultValue) { + this(fieldName, fieldNumber, fieldSize, fieldType, 0, 0, null, defaultValue); + } + + public FitMessageFieldDefinition(String fieldName, int fieldNumber, int fieldSize, FitFieldBaseType fieldType, double scale, double offset, String units, Object defaultValue) { + this.fieldName = fieldName; + this.fieldNumber = fieldNumber; + this.fieldSize = fieldSize; + this.fieldType = fieldType; + this.scale = scale; + this.offset = offset; + this.units = units; + this.defaultValue = defaultValue == null ? fieldType.invalidValue : defaultValue; + } + + public void writeToMessage(MessageWriter writer) { + writer.writeByte(fieldNumber); + writer.writeByte(fieldSize); + writer.writeByte(fieldType.typeID); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitParser.java new file mode 100644 index 000000000..3dd5528bc --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitParser.java @@ -0,0 +1,285 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit; + +import android.util.SparseArray; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.MessageReader; +import nodomain.freeyourgadget.gadgetbridge.util.ArrayUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +public class FitParser { + private static final Logger LOG = LoggerFactory.getLogger(FitParser.class); + + // “.FIT” – magic value indicating a .FIT file + private static final int FIT_MAGIC = 0x5449462E; + + private static final int FLAG_NORMAL_HEADER = 0x80; + private static final int FLAG_DEFINITION_MESSAGE = 0x40; + private static final int FLAG_DEVELOPER_FIELDS = 0x20; + private static final int MASK_LOCAL_MESSAGE_TYPE = 0x0F; + private static final int MASK_TIME_OFFSET = 0x1F; + private static final int MASK_COMPRESSED_LOCAL_MESSAGE_TYPE = 0x60; + + private final SparseArray globalMessageDefinitions; + private final SparseArray localMessageDefinitions = new SparseArray<>(16); + + public FitParser(Collection knownDefinitions) { + globalMessageDefinitions = new SparseArray<>(knownDefinitions.size()); + for (FitMessageDefinition definition : knownDefinitions) { + globalMessageDefinitions.append(definition.globalMessageID, definition); + } + } + + public SparseArray getLocalMessageDefinitions() { + return localMessageDefinitions; + } + + public List parseFitFile(byte[] data) { + if (data.length < 12) throw new IllegalArgumentException("Too short data"); + + final MessageReader reader = new MessageReader(data); + final List result = new ArrayList<>(); + while (!reader.isEof()) { + final int fileHeaderStart = reader.getPosition(); + final int fileHeaderSize = reader.readByte(); + final int protocolVersion = reader.readByte(); + final int profileVersion = reader.readShort(); + final int dataSize = reader.readInt(); + final int dataTypeMagic = reader.readInt(); + final int headerCrc = fileHeaderSize >= 14 ? reader.readShort() : 0; + if (dataTypeMagic != FIT_MAGIC) { + throw new IllegalArgumentException("Not a FIT file, data type signature not found"); + } + if (fileHeaderSize < 12) throw new IllegalArgumentException("Header size too low"); + reader.skip(fileHeaderSize - 14); + + // TODO: Check header CRC + + localMessageDefinitions.clear(); + + int lastTimestamp = 0; + final int end = fileHeaderStart + fileHeaderSize + dataSize; + while (reader.getPosition() < end) { + final int recordHeader = reader.readByte(); + final boolean isDefinitionMessage; + final int localMessageType; + final int currentTimestamp; + if ((recordHeader & FLAG_NORMAL_HEADER) == 0) { + // normal header + isDefinitionMessage = (recordHeader & FLAG_DEFINITION_MESSAGE) != 0; + localMessageType = recordHeader & MASK_LOCAL_MESSAGE_TYPE; + currentTimestamp = -1; + } else { + // compressed timestamp header + final int timestampOffset = recordHeader & MASK_TIME_OFFSET; + localMessageType = (recordHeader & MASK_COMPRESSED_LOCAL_MESSAGE_TYPE) >> 4; + currentTimestamp = lastTimestamp + timestampOffset; + isDefinitionMessage = false; + throw new IllegalArgumentException("Compressed timestamps not supported yet"); + } + + if (isDefinitionMessage) { + final boolean hasDeveloperFields = (recordHeader & FLAG_DEVELOPER_FIELDS) != 0; + final FitLocalMessageDefinition definition = parseDefinitionMessage(reader, hasDeveloperFields); + LOG.trace("Defining local message {} to global message {}", localMessageType, definition.globalDefinition.globalMessageID); + localMessageDefinitions.put(localMessageType, definition); + } else { + final FitLocalMessageDefinition definition = localMessageDefinitions.get(localMessageType); + if (definition == null) { + LOG.error("Use of undefined local message {}", localMessageType); + throw new IllegalArgumentException("Use of undefined local message " + localMessageType); + } + final FitMessage dataMessage = new FitMessage(definition.globalDefinition); + parseDataMessage(reader, definition, dataMessage); + result.add(dataMessage); + } + } + + final int fileCrc = reader.readShort(); + // TODO: Check file CRC + } + return result; + } + + private void parseDataMessage(MessageReader reader, FitLocalMessageDefinition localMessageDefinition, FitMessage dataMessage) { + for (FitLocalFieldDefinition localFieldDefinition : localMessageDefinition.fieldDefinitions) { + final Object value = readValue(reader, localFieldDefinition); + if (!localFieldDefinition.baseType.invalidValue.equals(value)) { + dataMessage.setField(localFieldDefinition.globalDefinition.fieldNumber, value); + } + } + } + + private Object readValue(MessageReader reader, FitLocalFieldDefinition fieldDefinition) { + //switch (fieldDefinition.baseType) { + switch (fieldDefinition.globalDefinition.fieldType) { + case ENUM: + case SINT8: + case UINT8: + case SINT16: + case UINT16: + case SINT32: + case UINT32: + case UINT8Z: + case UINT16Z: + case UINT32Z: + case SINT64: + case UINT64: + case UINT64Z: + return readFitNumber(reader, fieldDefinition.size, fieldDefinition.globalDefinition.scale, fieldDefinition.globalDefinition.offset); + case BYTE: + return fieldDefinition.size == 1 ? reader.readByte() : reader.readBytes(fieldDefinition.size); + case STRING: + return readFitString(reader, fieldDefinition.size); + case FLOAT32: + return readFloat32(reader, fieldDefinition.size); + case FLOAT64: + return readFloat64(reader, fieldDefinition.size); + // TODO: Float data types + default: + throw new IllegalArgumentException("Unable to read value of type " + fieldDefinition.baseType); + } + } + + private float readFloat32(MessageReader reader, int size) { + if (size != 4) { + throw new IllegalArgumentException("Invalid size for Float32: " + size); + } + final byte[] bytes = reader.readBytes(size); + return ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).getFloat(); + } + + private double readFloat64(MessageReader reader, int size) { + if (size != 8) { + throw new IllegalArgumentException("Invalid size for Float64: " + size); + } + final byte[] bytes = reader.readBytes(size); + return ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).getDouble(); + } + + private String readFitString(MessageReader reader, int size) { + final byte[] bytes = reader.readBytes(size); + final int zero = ArrayUtils.indexOf((byte) 0, bytes); + if (zero < 0) { + LOG.warn("Unterminated string"); + return new String(bytes, StandardCharsets.UTF_8); + } + return new String(bytes, 0, zero, StandardCharsets.UTF_8); + } + + private Object readRawFitNumber(MessageReader reader, int size) { + switch (size) { + case 1: + return reader.readByte(); + case 2: + return reader.readShort(); + case 3: { + // this is strange? + byte[] bytes = new byte[4]; + reader.readBytesTo(3, bytes, 0); + return BinaryUtils.readInt(bytes, 0); + } + case 4: + return reader.readInt(); + case 7: { + // this is strange? + byte[] bytes = new byte[8]; + reader.readBytesTo(7, bytes, 0); + return BinaryUtils.readLong(bytes, 0); + } + case 8: + return reader.readLong(); + case 12: + // this is strange? + long lower = reader.readLong(); + int upper = reader.readInt(); + return upper * ((double) Long.MAX_VALUE) + lower; + case 16: + // this is strange? + return reader.readLong() + reader.readLong() * (double) (Long.MAX_VALUE); + case 32: + // this is strange? + // TODO: FIXME: 32-byte integer?!? + reader.skip(16); + return Math.pow(2, 128) * (reader.readLong() + reader.readLong() * (double) (Long.MAX_VALUE)); + default: + throw new IllegalArgumentException("Unable to read number of size " + size); + } + } + + private Object readFitNumber(MessageReader reader, int size, double scale, double offset) { + if (scale == 0) { + return readRawFitNumber(reader, size); + } else { + switch (size) { + case 1: + return reader.readByte() / scale + offset; + case 2: + return reader.readShort() / scale + offset; + case 4: + return reader.readInt() / scale + offset; + case 8: + return reader.readLong() / scale + offset; + default: + throw new IllegalArgumentException("Unable to read number of size " + size); + } + } + } + + private FitLocalMessageDefinition parseDefinitionMessage(MessageReader reader, boolean hasDeveloperFields) { + reader.skip(1); + final int architecture = reader.readByte(); + final boolean isBigEndian = architecture == 1; + if (isBigEndian) throw new IllegalArgumentException("Big-endian data not supported yet"); + final int globalMessageType = reader.readShort(); + final FitMessageDefinition messageDefinition = getGlobalDefinition(globalMessageType); + + final int fieldCount = reader.readByte(); + final List fields = new ArrayList<>(fieldCount); + for (int i = 0; i < fieldCount; ++i) { + final int globalField = reader.readByte(); + final int size = reader.readByte(); + final int baseTypeNum = reader.readByte(); + final FitFieldBaseType baseType = FitFieldBaseType.decodeTypeID(baseTypeNum); + + final FitMessageFieldDefinition globalFieldDefinition = getFieldDefinition(messageDefinition, globalField, size, baseType); + + fields.add(new FitLocalFieldDefinition(globalFieldDefinition, size, baseType)); + } + if (hasDeveloperFields) { + final int developerFieldCount = reader.readByte(); + if (developerFieldCount != 0) throw new IllegalArgumentException("Developer fields not supported yet"); + } + + return new FitLocalMessageDefinition(messageDefinition, fields); + } + + private FitMessageFieldDefinition getFieldDefinition(FitMessageDefinition messageDefinition, int field, int size, FitFieldBaseType baseType) { + final FitMessageFieldDefinition definition = messageDefinition.getField(field); + if (definition != null) return definition; + + LOG.warn("Unknown field {} in message {}", field, messageDefinition.globalMessageID); + // System.out.println(String.format(Locale.ROOT, "Unknown field %d in message %d", field, messageDefinition.globalMessageID)); + final FitMessageFieldDefinition newDefinition = new FitMessageFieldDefinition("unknown_" + field, field, size, baseType, baseType.invalidValue); + messageDefinition.addField(newDefinition); + return newDefinition; + } + + private FitMessageDefinition getGlobalDefinition(int globalMessageType) { + final FitMessageDefinition messageDefinition = globalMessageDefinitions.get(globalMessageType); + if (messageDefinition != null) return messageDefinition; + + LOG.warn("Unknown global message {}", globalMessageType); + // System.out.println(String.format(Locale.ROOT, "Unknown message %d", globalMessageType)); + final FitMessageDefinition newDefinition = new FitMessageDefinition("unknown_" + globalMessageType, globalMessageType, 0); + globalMessageDefinitions.append(globalMessageType, newDefinition); + return newDefinition; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitSerializer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitSerializer.java new file mode 100644 index 000000000..f382eb58f --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitSerializer.java @@ -0,0 +1,258 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit; + +import android.util.SparseArray; +import android.util.SparseBooleanArray; +import android.util.SparseIntArray; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.MessageWriter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +public class FitSerializer { + private static final Logger LOG = LoggerFactory.getLogger(FitSerializer.class); + + private final SparseBooleanArray knownMessageIDs = new SparseBooleanArray(16); + private final SparseIntArray localMessageIDs = new SparseIntArray(16); + private final SparseArray localMessageDefinitions; + + // “.FIT” – magic value indicating a .FIT file + private static final int FIT_MAGIC = 0x5449462E; + + private static final int FLAG_NORMAL_HEADER = 0x80; + private static final int FLAG_DEFINITION_MESSAGE = 0x40; + private static final int FLAG_DEVELOPER_FIELDS = 0x20; + private static final int MASK_LOCAL_MESSAGE_TYPE = 0x0F; + private static final int MASK_TIME_OFFSET = 0x1F; + private static final int MASK_COMPRESSED_LOCAL_MESSAGE_TYPE = 0x60; + + public FitSerializer() { + this(new SparseArray(16)); + } + + public FitSerializer(SparseArray initialDefinitions) { + this.localMessageDefinitions = initialDefinitions; + for (int i = 0; i < initialDefinitions.size(); ++i) { + final int localId = initialDefinitions.keyAt(i); + final FitLocalMessageDefinition definition = initialDefinitions.valueAt(i); + knownMessageIDs.put(definition.globalDefinition.globalMessageID, true); + localMessageIDs.put(definition.globalDefinition.globalMessageID, localId); + } + } + + public byte[] serializeFitFile(List messages) { + final MessageWriter writer = new MessageWriter(); + writer.writeByte(14); + writer.writeByte(0x10); + writer.writeShort(2057); + // dataSize will be rewritten later + writer.writeInt(0); + writer.writeInt(FIT_MAGIC); + // CRC will be rewritten later + writer.writeShort(0); + + // first, gather additional needed definitions (if any) + for (final FitMessage message : messages) { + final FitMessageDefinition messageDefinition = message.definition; + final int globalMessageID = messageDefinition.globalMessageID; + if (!knownMessageIDs.get(globalMessageID)) { + LOG.debug("FitSerializer needs to add definition for {}", globalMessageID); + final int localMessageID = localMessageIDs.size() == 0 ? 0 : localMessageIDs.keyAt(localMessageIDs.size() - 1) + 1; + localMessageIDs.put(globalMessageID, localMessageID); + knownMessageIDs.put(globalMessageID, true); + final List fieldDefinitions = messageDefinition.fieldDefinitions; + final List localFieldDefinitions = new ArrayList<>(fieldDefinitions.size()); + for (FitMessageFieldDefinition definition : fieldDefinitions) { + localFieldDefinitions.add(new FitLocalFieldDefinition(definition, definition.fieldSize, definition.fieldType)); + } + localMessageDefinitions.put(localMessageID, new FitLocalMessageDefinition(messageDefinition, localFieldDefinitions)); + } + } + // now, write definition messages for all used message types + final SparseBooleanArray definedMessages = new SparseBooleanArray(); + for (final FitMessage message : messages) { + int localMessageID = localMessageIDs.get(message.definition.globalMessageID); + if (!definedMessages.get(localMessageID)) { + definedMessages.put(localMessageID, true); + + writeDefinitionMessage(writer, localMessageID, localMessageDefinitions.get(localMessageID)); + } + } + + // and now, write the data messages + for (final FitMessage message : messages) { + int localMessageID = localMessageIDs.get(message.definition.globalMessageID); + final FitLocalMessageDefinition localMessageDefinition = localMessageDefinitions.get(localMessageID); + writeDataMessage(writer, message, localMessageID, localMessageDefinition); + } + + writer.writeShort(ChecksumCalculator.computeCrc(writer.peekBytes(), 14, writer.getSize() - 14)); + + final byte[] bytes = writer.getBytes(); + // rewrite size + BinaryUtils.writeInt(bytes, 4, bytes.length - 14 - 2); + // rewrite header CRC + BinaryUtils.writeShort(bytes, 12, ChecksumCalculator.computeCrc(bytes, 0, 12)); + return bytes; + } + + private void writeDefinitionMessage(MessageWriter writer, int localMessageID, FitLocalMessageDefinition localMessageDefinition) { + writer.writeByte(FLAG_DEFINITION_MESSAGE | localMessageID); + writer.writeByte(0); + writer.writeByte(0); + writer.writeShort(localMessageDefinition.globalDefinition.globalMessageID); + writer.writeByte(localMessageDefinition.fieldDefinitions.size()); + for (FitLocalFieldDefinition localFieldDefinition : localMessageDefinition.fieldDefinitions) { + writer.writeByte(localFieldDefinition.globalDefinition.fieldNumber); + writer.writeByte(localFieldDefinition.size); + writer.writeByte(localFieldDefinition.baseType.typeID); + } + } + + private void writeDataMessage(MessageWriter writer, FitMessage message, int localMessageID, FitLocalMessageDefinition localMessageDefinition) { + writer.writeByte(localMessageID); + + for (FitLocalFieldDefinition localFieldDefinition : localMessageDefinition.fieldDefinitions) { + Object value = message.getField(localFieldDefinition.globalDefinition.fieldNumber); + if (value == null) { + value = localFieldDefinition.baseType.invalidValue; + } + writeValue(writer, localFieldDefinition, value); + } + } + + private void writeValue(MessageWriter writer, FitLocalFieldDefinition fieldDefinition, Object value) { + switch (fieldDefinition.globalDefinition.fieldType) { + case ENUM: + case SINT8: + case UINT8: + case SINT16: + case UINT16: + case SINT32: + case UINT32: + case UINT8Z: + case UINT16Z: + case UINT32Z: + case SINT64: + case UINT64: + case UINT64Z: + writeFitNumber(writer, value, fieldDefinition.size, fieldDefinition.globalDefinition.scale, fieldDefinition.globalDefinition.offset); + break; + case BYTE: + if (fieldDefinition.size == 1) { + writer.writeByte((int) value); + } else { + writer.writeBytes((byte[]) value); + } + break; + case STRING: + writeFitString(writer, (String) value, fieldDefinition.size); + break; + case FLOAT32: + writeFloat32(writer, (float) value); + break; + case FLOAT64: + writeFloat64(writer, (double) value); + break; + default: + throw new IllegalArgumentException("Unable to write value of type " + fieldDefinition.baseType); + } + } + + private void writeFitString(MessageWriter writer, String value, int size) { + if (value.length() >= size) throw new IllegalArgumentException("Too long string"); + final byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + writer.writeBytes(bytes); + final byte[] zeroes = new byte[size - value.length()]; + writer.writeBytes(zeroes); + } + + private void writeFloat32(MessageWriter writer, float value) { + writer.writeBytes(ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putFloat(value).array(), 0, 4); + } + + private void writeFloat64(MessageWriter writer, double value) { + writer.writeBytes(ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putDouble(value).array(), 0, 4); + } + + private void writeFitNumber(MessageWriter writer, Object value, int size, double scale, double offset) { + if (scale == 0) { + writeRawFitNumber(writer, value, size); + } else { + final long rawValue = Math.round((double) value * scale - offset); + switch (size) { + case 1: + writer.writeByte((int) rawValue); + break; + case 2: + writer.writeShort((int) rawValue); + break; + case 4: + writer.writeInt((int) rawValue); + break; + case 8: + writer.writeLong(rawValue); + break; + default: + throw new IllegalArgumentException("Unable to write number of size " + size); + } + } + } + + private void writeRawFitNumber(MessageWriter writer, Object value, int size) { + switch (size) { + case 1: + writer.writeByte((int) value); + break; + case 2: + writer.writeShort((int) value); + break; + case 3: { + // this is strange? + byte[] bytes = new byte[4]; + BinaryUtils.writeInt(bytes, 0, (int) value); + writer.writeBytes(bytes, 0, 3); + break; + } + case 4: + writer.writeInt((int) value); + break; + case 7: { + // this is strange? + byte[] bytes = new byte[8]; + BinaryUtils.writeLong(bytes, 0, (long) value); + writer.writeBytes(bytes, 0, 7); + break; + } + case 8: + writer.writeLong((long) value); + break; + case 12: { + // this is strange? (and probably losing precision anyway) + final double val = (double) value; + final long upper = Math.round(val / Long.MAX_VALUE); + final long lower = Math.round(val - upper); + writer.writeLong(lower); + writer.writeInt((int) upper); + break; + } + case 16: { + // this is strange? (and probably losing precision anyway) + final double val = (double) value; + final long upper = Math.round(val / Long.MAX_VALUE); + final long lower = Math.round(val - upper); + writer.writeLong(lower); + writer.writeLong(upper); + break; + } + default: + throw new IllegalArgumentException("Unable to read number of size " + size); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitWeatherConditions.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitWeatherConditions.java new file mode 100644 index 000000000..49c95b5ca --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/fit/FitWeatherConditions.java @@ -0,0 +1,191 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit; + +public final class FitWeatherConditions { + public static final int CLEAR = 0; + public static final int PARTLY_CLOUDY = 1; + public static final int MOSTLY_CLOUDY = 2; + public static final int RAIN = 3; + public static final int SNOW = 4; + public static final int WINDY = 5; + public static final int THUNDERSTORMS = 6; + public static final int WINTRY_MIX = 7; + public static final int FOG = 8; + public static final int HAZY = 11; + public static final int HAIL = 12; + public static final int SCATTERED_SHOWERS = 13; + public static final int SCATTERED_THUNDERSTORMS = 14; + public static final int UNKNOWN_PRECIPITATION = 15; + public static final int LIGHT_RAIN = 16; + public static final int HEAVY_RAIN = 17; + public static final int LIGHT_SNOW = 18; + public static final int HEAVY_SNOW = 19; + public static final int LIGHT_RAIN_SNOW = 20; + public static final int HEAVY_RAIN_SNOW = 21; + public static final int CLOUDY = 22; + + public static final int ALERT_SEVERITY_UNKNOWN = 0; + public static final int ALERT_SEVERITY_WARNING = 1; + public static final int ALERT_SEVERITY_WATCH = 2; + public static final int ALERT_SEVERITY_ADVISORY = 3; + public static final int ALERT_SEVERITY_STATEMENT = 4; + + public static final int ALERT_TYPE_UNSPECIFIED = 0; + public static final int ALERT_TYPE_TORNADO = 1; + public static final int ALERT_TYPE_TSUNAMI = 2; + public static final int ALERT_TYPE_HURRICANE = 3; + public static final int ALERT_TYPE_EXTREME_WIND = 4; + public static final int ALERT_TYPE_TYPHOON = 5; + public static final int ALERT_TYPE_INLAND_HURRICANE = 6; + public static final int ALERT_TYPE_HURRICANE_FORCE_WIND = 7; + public static final int ALERT_TYPE_WATERSPOUT = 8; + public static final int ALERT_TYPE_SEVERE_THUNDERSTORM = 9; + public static final int ALERT_TYPE_WRECKHOUSE_WINDS = 10; + public static final int ALERT_TYPE_LES_SUETES_WIND = 11; + public static final int ALERT_TYPE_AVALANCHE = 12; + public static final int ALERT_TYPE_FLASH_FLOOD = 13; + public static final int ALERT_TYPE_TROPICAL_STORM = 14; + public static final int ALERT_TYPE_INLAND_TROPICAL_STORM = 15; + public static final int ALERT_TYPE_BLIZZARD = 16; + public static final int ALERT_TYPE_ICE_STORM = 17; + public static final int ALERT_TYPE_FREEZING_RAIN = 18; + public static final int ALERT_TYPE_DEBRIS_FLOW = 19; + public static final int ALERT_TYPE_FLASH_FREEZE = 20; + public static final int ALERT_TYPE_DUST_STORM = 21; + public static final int ALERT_TYPE_HIGH_WIND = 22; + public static final int ALERT_TYPE_WINTER_STORM = 23; + public static final int ALERT_TYPE_HEAVY_FREEZING_SPRAY = 24; + public static final int ALERT_TYPE_EXTREME_COLD = 25; + public static final int ALERT_TYPE_WIND_CHILL = 26; + public static final int ALERT_TYPE_COLD_WAVE = 27; + public static final int ALERT_TYPE_HEAVY_SNOW_ALERT = 28; + public static final int ALERT_TYPE_LAKE_EFFECT_BLOWING_SNOW = 29; + public static final int ALERT_TYPE_SNOW_SQUALL = 30; + public static final int ALERT_TYPE_LAKE_EFFECT_SNOW = 31; + public static final int ALERT_TYPE_WINTER_WEATHER = 32; + public static final int ALERT_TYPE_SLEET = 33; + public static final int ALERT_TYPE_SNOWFALL = 34; + public static final int ALERT_TYPE_SNOW_AND_BLOWING_SNOW = 35; + public static final int ALERT_TYPE_BLOWING_SNOW = 36; + public static final int ALERT_TYPE_SNOW_ALERT = 37; + public static final int ALERT_TYPE_ARCTIC_OUTFLOW = 38; + public static final int ALERT_TYPE_FREEZING_DRIZZLE = 39; + public static final int ALERT_TYPE_STORM = 40; + public static final int ALERT_TYPE_STORM_SURGE = 41; + public static final int ALERT_TYPE_RAINFALL = 42; + public static final int ALERT_TYPE_AREAL_FLOOD = 43; + public static final int ALERT_TYPE_COASTAL_FLOOD = 44; + public static final int ALERT_TYPE_LAKESHORE_FLOOD = 45; + public static final int ALERT_TYPE_EXCESSIVE_HEAT = 46; + public static final int ALERT_TYPE_HEAT = 47; + public static final int ALERT_TYPE_WEATHER = 48; + public static final int ALERT_TYPE_HIGH_HEAT_AND_HUMIDITY = 49; + public static final int ALERT_TYPE_HUMIDEX_AND_HEALTH = 50; + public static final int ALERT_TYPE_HUMIDEX = 51; + public static final int ALERT_TYPE_GALE = 52; + public static final int ALERT_TYPE_FREEZING_SPRAY = 53; + public static final int ALERT_TYPE_SPECIAL_MARINE = 54; + public static final int ALERT_TYPE_SQUALL = 55; + public static final int ALERT_TYPE_STRONG_WIND = 56; + public static final int ALERT_TYPE_LAKE_WIND = 57; + public static final int ALERT_TYPE_MARINE_WEATHER = 58; + public static final int ALERT_TYPE_WIND = 59; + public static final int ALERT_TYPE_SMALL_CRAFT_HAZARDOUS_SEAS = 60; + public static final int ALERT_TYPE_HAZARDOUS_SEAS = 61; + public static final int ALERT_TYPE_SMALL_CRAFT = 62; + public static final int ALERT_TYPE_SMALL_CRAFT_WINDS = 63; + public static final int ALERT_TYPE_SMALL_CRAFT_ROUGH_BAR = 64; + public static final int ALERT_TYPE_HIGH_WATER_LEVEL = 65; + public static final int ALERT_TYPE_ASHFALL = 66; + public static final int ALERT_TYPE_FREEZING_FOG = 67; + public static final int ALERT_TYPE_DENSE_FOG = 68; + public static final int ALERT_TYPE_DENSE_SMOKE = 69; + public static final int ALERT_TYPE_BLOWING_DUST = 70; + public static final int ALERT_TYPE_HARD_FREEZE = 71; + public static final int ALERT_TYPE_FREEZE = 72; + public static final int ALERT_TYPE_FROST = 73; + public static final int ALERT_TYPE_FIRE_WEATHER = 74; + public static final int ALERT_TYPE_FLOOD = 75; + public static final int ALERT_TYPE_RIP_TIDE = 76; + public static final int ALERT_TYPE_HIGH_SURF = 77; + public static final int ALERT_TYPE_SMOG = 78; + public static final int ALERT_TYPE_AIR_QUALITY = 79; + public static final int ALERT_TYPE_BRISK_WIND = 80; + public static final int ALERT_TYPE_AIR_STAGNATION = 81; + public static final int ALERT_TYPE_LOW_WATER = 82; + public static final int ALERT_TYPE_HYDROLOGICAL = 83; + public static final int ALERT_TYPE_SPECIAL_WEATHER = 84; + + public static int openWeatherCodeToFitWeatherStatus(int openWeatherCode) { + switch (openWeatherCode) { + case 800: + return CLEAR; + case 801: + case 802: + return PARTLY_CLOUDY; + case 803: + return MOSTLY_CLOUDY; + case 804: + return CLOUDY; + case 701: + case 721: + return HAZY; + case 741: + return FOG; + case 771: + case 781: + return WINDY; + case 615: + return LIGHT_RAIN_SNOW; + case 616: + return HEAVY_RAIN_SNOW; + case 611: + case 612: + case 613: + return WINTRY_MIX; + case 500: + case 520: + case 521: + case 300: + case 310: + case 313: + return LIGHT_RAIN; + case 501: + case 531: + case 301: + case 311: + return RAIN; + case 502: + case 503: + case 504: + case 522: + case 302: + case 312: + case 314: + return HEAVY_RAIN; + case 321: + return SCATTERED_SHOWERS; + case 511: + return UNKNOWN_PRECIPITATION; + case 200: + case 201: + case 202: + case 210: + case 211: + case 212: + case 230: + case 231: + case 232: + return THUNDERSTORMS; + case 221: + return SCATTERED_THUNDERSTORMS; + case 600: + return LIGHT_SNOW; + case 601: + return SNOW; + case 602: + return HEAVY_SNOW; + default: + throw new IllegalArgumentException("Unknown weather code " + openWeatherCode); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/AuthNegotiationMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/AuthNegotiationMessage.java new file mode 100644 index 000000000..d53a1c2f7 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/AuthNegotiationMessage.java @@ -0,0 +1,29 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; + +public class AuthNegotiationMessage { + public static final int LONG_TERM_KEY_AVAILABILITY_NONE = 0; + public static final int LONG_TERM_KEY_AVAILABILITY_SOME_AVAILABLE = 1; + + public static final int ENCRYPTION_ALGORITHM_NONE = 0; + public static final int ENCRYPTION_ALGORITHM_XXTEA = 1 << 0; + public static final int ENCRYPTION_ALGORITHM_AES128 = 1 << 1; + + public final byte[] packet; + + public AuthNegotiationMessage(int longTermKeyAvailability, int supportedEncryptionAlgorithms) { + final MessageWriter writer = new MessageWriter(11); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(VivomoveConstants.MESSAGE_AUTH_NEGOTIATION); + writer.writeByte(longTermKeyAvailability); + writer.writeInt(supportedEncryptionAlgorithms); + writer.writeShort(0); // CRC will be filled below + final byte[] packet = writer.getBytes(); + BinaryUtils.writeShort(packet, 0, packet.length); + BinaryUtils.writeShort(packet, packet.length - 2, ChecksumCalculator.computeCrc(packet, 0, packet.length - 2)); + this.packet = packet; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/AuthNegotiationResponseMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/AuthNegotiationResponseMessage.java new file mode 100644 index 000000000..f71ca7b2f --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/AuthNegotiationResponseMessage.java @@ -0,0 +1,26 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +public class AuthNegotiationResponseMessage { + public final int status; + public final int response; + public final int longTermKeyAvailability; + public final int supportedEncryptionAlgorithms; + + public AuthNegotiationResponseMessage(int status, int response, int longTermKeyAvailability, int supportedEncryptionAlgorithms) { + this.status = status; + this.response = response; + this.longTermKeyAvailability = longTermKeyAvailability; + this.supportedEncryptionAlgorithms = supportedEncryptionAlgorithms; + } + + public static AuthNegotiationResponseMessage parsePacket(byte[] packet) { + final MessageReader reader = new MessageReader(packet, 4); + final int requestID = reader.readShort(); + final int status = reader.readByte(); + final int response = reader.readByte(); + final int longTermKeyAvailability = reader.readByte(); + final int supportedEncryptionAlgorithms = reader.readInt(); + + return new AuthNegotiationResponseMessage(status, response, longTermKeyAvailability, supportedEncryptionAlgorithms); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/BatteryStatusMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/BatteryStatusMessage.java new file mode 100644 index 000000000..4cadc056d --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/BatteryStatusMessage.java @@ -0,0 +1,23 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; + +public class BatteryStatusMessage { + public final byte[] packet; + + public BatteryStatusMessage(int batteryPercentage) { + final MessageWriter writer = new MessageWriter(9); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(VivomoveConstants.MESSAGE_BATTERY_STATUS); + writer.writeByte(255); + writer.writeByte(batteryPercentage); + writer.writeByte(255); + writer.writeShort(0); // CRC will be filled below + final byte[] packet = writer.getBytes(); + BinaryUtils.writeShort(packet, 0, packet.length); + BinaryUtils.writeShort(packet, packet.length - 2, ChecksumCalculator.computeCrc(packet, 0, packet.length - 2)); + this.packet = packet; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/ConfigurationMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/ConfigurationMessage.java new file mode 100644 index 000000000..f1f085ba4 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/ConfigurationMessage.java @@ -0,0 +1,34 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; + +import java.util.Arrays; + +public class ConfigurationMessage { + public final byte[] packet; + public final byte[] configurationPayload; + + public ConfigurationMessage(byte[] configurationPayload) { + if (configurationPayload.length > 255) throw new IllegalArgumentException("Too long payload"); + this.configurationPayload = configurationPayload; + + final MessageWriter writer = new MessageWriter(7 + configurationPayload.length); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(VivomoveConstants.MESSAGE_CONFIGURATION); + writer.writeByte(configurationPayload.length); + writer.writeBytes(configurationPayload); + writer.writeShort(0); // CRC will be filled below + final byte[] packet = writer.getBytes(); + BinaryUtils.writeShort(packet, 0, packet.length); + BinaryUtils.writeShort(packet, packet.length - 2, ChecksumCalculator.computeCrc(packet, 0, packet.length - 2)); + this.packet = packet; + } + + public static ConfigurationMessage parsePacket(byte[] packet) { + final MessageReader reader = new MessageReader(packet, 4); + final int payloadSize = reader.readByte(); + return new ConfigurationMessage(Arrays.copyOfRange(packet, 5, payloadSize)); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/CreateFileRequestMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/CreateFileRequestMessage.java new file mode 100644 index 000000000..b112edfa6 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/CreateFileRequestMessage.java @@ -0,0 +1,28 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; + +public class CreateFileRequestMessage { + public final byte[] packet; + + public CreateFileRequestMessage(int fileSize, int dataType, int subType, int fileIdentifier, int subTypeMask, int numberMask, String path) { + final MessageWriter writer = new MessageWriter(); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(VivomoveConstants.MESSAGE_CREATE_FILE_REQUEST); + writer.writeInt(fileSize); + writer.writeByte(dataType); + writer.writeByte(subType); + writer.writeShort(fileIdentifier); + writer.writeByte(0); // reserved + writer.writeByte(subTypeMask); + writer.writeShort(numberMask); + writer.writeString(path); + writer.writeShort(0); // CRC will be filled below + final byte[] packet = writer.getBytes(); + BinaryUtils.writeShort(packet, 0, packet.length); + BinaryUtils.writeShort(packet, packet.length - 2, ChecksumCalculator.computeCrc(packet, 0, packet.length - 2)); + this.packet = packet; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/CreateFileResponseMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/CreateFileResponseMessage.java new file mode 100644 index 000000000..908e0f8b5 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/CreateFileResponseMessage.java @@ -0,0 +1,38 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +public class CreateFileResponseMessage { + public static final byte RESPONSE_FILE_CREATED_SUCCESSFULLY = 0; + public static final byte RESPONSE_FILE_ALREADY_EXISTS = 1; + public static final byte RESPONSE_NOT_ENOUGH_SPACE = 2; + public static final byte RESPONSE_NOT_SUPPORTED = 3; + public static final byte RESPONSE_NO_SLOTS_AVAILABLE_FOR_FILE_TYPE = 4; + public static final byte RESPONSE_NOT_ENOUGH_SPACE_FOR_FILE_TYPE = 5; + + public final int status; + public final int response; + public final int fileIndex; + public final int dataType; + public final int subType; + public final int fileNumber; + + public CreateFileResponseMessage(int status, int response, int fileIndex, int dataType, int subType, int fileNumber) { + this.status = status; + this.response = response; + this.fileIndex = fileIndex; + this.dataType = dataType; + this.subType = subType; + this.fileNumber = fileNumber; + } + + public static CreateFileResponseMessage parsePacket(byte[] packet) { + final MessageReader reader = new MessageReader(packet, 6); + final int status = reader.readByte(); + final int response = reader.readByte(); + final int fileIndex = reader.readShort(); + final int dataType = reader.readByte(); + final int subType = reader.readByte(); + final int fileNumber = reader.readShort(); + + return new CreateFileResponseMessage(status, response, fileIndex, dataType, subType, fileNumber); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/CurrentTimeRequestMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/CurrentTimeRequestMessage.java new file mode 100644 index 000000000..b54dc713f --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/CurrentTimeRequestMessage.java @@ -0,0 +1,16 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +public class CurrentTimeRequestMessage { + public final int referenceID; + + public CurrentTimeRequestMessage(int referenceID) { + this.referenceID = referenceID; + } + + public static CurrentTimeRequestMessage parsePacket(byte[] packet) { + final MessageReader reader = new MessageReader(packet, 4); + final int referenceID = reader.readInt(); + + return new CurrentTimeRequestMessage(referenceID); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/CurrentTimeRequestResponseMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/CurrentTimeRequestResponseMessage.java new file mode 100644 index 000000000..1e38ce4b9 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/CurrentTimeRequestResponseMessage.java @@ -0,0 +1,28 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; + +public class CurrentTimeRequestResponseMessage { + public final byte[] packet; + + public CurrentTimeRequestResponseMessage(int status, int referenceID, int garminTimestamp, int timeZoneOffset, int dstOffset) { + final MessageWriter writer = new MessageWriter(29); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(VivomoveConstants.MESSAGE_RESPONSE); + writer.writeShort(VivomoveConstants.MESSAGE_CURRENT_TIME_REQUEST); + writer.writeByte(status); + writer.writeInt(referenceID); + writer.writeInt(garminTimestamp); + writer.writeInt(timeZoneOffset); + // TODO: next DST start/end + writer.writeInt(0); + writer.writeInt(0); + writer.writeShort(0); // CRC will be filled below + final byte[] packet = writer.getBytes(); + BinaryUtils.writeShort(packet, 0, packet.length); + BinaryUtils.writeShort(packet, packet.length - 2, ChecksumCalculator.computeCrc(packet, 0, packet.length - 2)); + this.packet = packet; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/DeviceInformationMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/DeviceInformationMessage.java new file mode 100644 index 000000000..c4f2a228a --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/DeviceInformationMessage.java @@ -0,0 +1,46 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import java.util.Locale; + +public class DeviceInformationMessage { + public final int protocolVersion; + public final int productNumber; + public final String unitNumber; + public final int softwareVersion; + public final int maxPacketSize; + public final String bluetoothFriendlyName; + public final String deviceName; + public final String deviceModel; + // dual-pairing flags & MAC addresses... + + public DeviceInformationMessage(int protocolVersion, int productNumber, String unitNumber, int softwareVersion, int maxPacketSize, String bluetoothFriendlyName, String deviceName, String deviceModel) { + this.protocolVersion = protocolVersion; + this.productNumber = productNumber; + this.unitNumber = unitNumber; + this.softwareVersion = softwareVersion; + this.maxPacketSize = maxPacketSize; + this.bluetoothFriendlyName = bluetoothFriendlyName; + this.deviceName = deviceName; + this.deviceModel = deviceModel; + } + + public static DeviceInformationMessage parsePacket(byte[] packet) { + final MessageReader reader = new MessageReader(packet, 4); + 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(); + + return new DeviceInformationMessage(protocolVersion, productNumber, unitNumber, softwareVersion, maxPacketSize, bluetoothFriendlyName, deviceName, deviceModel); + } + + public String getSoftwareVersionStr() { + int softwareVersionMajor = softwareVersion / 100; + int softwareVersionMinor = softwareVersion % 100; + return String.format(Locale.ROOT, "%d.%02d", softwareVersionMajor, softwareVersionMinor); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/DeviceInformationResponseMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/DeviceInformationResponseMessage.java new file mode 100644 index 000000000..dff8bfd24 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/DeviceInformationResponseMessage.java @@ -0,0 +1,31 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; + +public class DeviceInformationResponseMessage { + public final byte[] packet; + + public DeviceInformationResponseMessage(int status, int protocolVersion, int productNumber, int unitNumber, int softwareVersion, int maxPacketSize, String bluetoothFriendlyName, String deviceName, String deviceModel, int protocolFlags) { + final MessageWriter writer = new MessageWriter(); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(VivomoveConstants.MESSAGE_RESPONSE); + writer.writeShort(VivomoveConstants.MESSAGE_DEVICE_INFORMATION); + writer.writeByte(status); + writer.writeShort(protocolVersion); + writer.writeShort(productNumber); + writer.writeInt(unitNumber); + writer.writeShort(softwareVersion); + writer.writeShort(maxPacketSize); + writer.writeString(bluetoothFriendlyName); + writer.writeString(deviceName); + writer.writeString(deviceModel); + writer.writeByte(protocolFlags); + writer.writeShort(0); // CRC will be filled below + final byte[] packet = writer.getBytes(); + BinaryUtils.writeShort(packet, 0, packet.length); + BinaryUtils.writeShort(packet, packet.length - 2, ChecksumCalculator.computeCrc(packet, 0, packet.length - 2)); + this.packet = packet; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/DirectoryFileFilterRequestMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/DirectoryFileFilterRequestMessage.java new file mode 100644 index 000000000..208595dcf --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/DirectoryFileFilterRequestMessage.java @@ -0,0 +1,26 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; + +public class DirectoryFileFilterRequestMessage { + public static final int FILTER_NO_FILTER = 0; + public static final int FILTER_DEVICE_DEFAULT_FILTER = 1; + public static final int FILTER_CUSTOM_FILTER = 2; + public static final int FILTER_PENDING_UPLOADS_ONLY = 3; + + public final byte[] packet; + + public DirectoryFileFilterRequestMessage(int filterType) { + final MessageWriter writer = new MessageWriter(7); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(VivomoveConstants.MESSAGE_DIRECTORY_FILE_FILTER_REQUEST); + writer.writeByte(filterType); + writer.writeShort(0); // CRC will be filled below + final byte[] packet = writer.getBytes(); + BinaryUtils.writeShort(packet, 0, packet.length); + BinaryUtils.writeShort(packet, packet.length - 2, ChecksumCalculator.computeCrc(packet, 0, packet.length - 2)); + this.packet = packet; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/DirectoryFileFilterResponseMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/DirectoryFileFilterResponseMessage.java new file mode 100644 index 000000000..3aed181c0 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/DirectoryFileFilterResponseMessage.java @@ -0,0 +1,23 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +public class DirectoryFileFilterResponseMessage { + public final int status; + public final int response; + + public static final int RESPONSE_DIRECTORY_FILTER_APPLIED = 0; + public static final int RESPONSE_FAILED_TO_APPLY_DIRECTORY_FILTER = 1; + + public DirectoryFileFilterResponseMessage(int status, int response) { + this.status = status; + this.response = response; + } + + public static DirectoryFileFilterResponseMessage parsePacket(byte[] packet) { + final MessageReader reader = new MessageReader(packet, 4); + final int requestID = reader.readShort(); + final int status = reader.readByte(); + final int response = reader.readByte(); + + return new DirectoryFileFilterResponseMessage(status, response); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/DownloadRequestMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/DownloadRequestMessage.java new file mode 100644 index 000000000..fe20eed7a --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/DownloadRequestMessage.java @@ -0,0 +1,28 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; + +public class DownloadRequestMessage { + public static final int REQUEST_CONTINUE_TRANSFER = 0; + public static final int REQUEST_NEW_TRANSFER = 1; + + public final byte[] packet; + + public DownloadRequestMessage(int fileIndex, int dataOffset, int request, int crcSeed, int dataSize) { + final MessageWriter writer = new MessageWriter(19); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(VivomoveConstants.MESSAGE_DOWNLOAD_REQUEST); + writer.writeShort(fileIndex); + writer.writeInt(dataOffset); + writer.writeByte(request); + writer.writeShort(crcSeed); + writer.writeInt(dataSize); + writer.writeShort(0); // CRC will be filled below + final byte[] packet = writer.getBytes(); + BinaryUtils.writeShort(packet, 0, packet.length); + BinaryUtils.writeShort(packet, packet.length - 2, ChecksumCalculator.computeCrc(packet, 0, packet.length - 2)); + this.packet = packet; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/DownloadRequestResponseMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/DownloadRequestResponseMessage.java new file mode 100644 index 000000000..e3e070869 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/DownloadRequestResponseMessage.java @@ -0,0 +1,31 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +public class DownloadRequestResponseMessage { + public final int status; + public final int response; + public final int fileSize; + + public static final byte RESPONSE_DOWNLOAD_REQUEST_OKAY = 0; + public static final byte RESPONSE_DATA_DOES_NOT_EXIST = 1; + public static final byte RESPONSE_DATA_EXISTS_BUT_IS_NOT_DOWNLOADABLE = 2; + public static final byte RESPONSE_NOT_READY_TO_DOWNLOAD = 3; + public static final byte RESPONSE_REQUEST_INVALID = 4; + public static final byte RESPONSE_CRC_INCORRECT = 5; + public static final byte RESPONSE_DATA_REQUESTED_EXCEEDS_FILE_SIZE = 6; + + public DownloadRequestResponseMessage(int status, int response, int fileSize) { + this.status = status; + this.response = response; + this.fileSize = fileSize; + } + + public static DownloadRequestResponseMessage parsePacket(byte[] packet) { + final MessageReader reader = new MessageReader(packet, 4); + final int requestID = reader.readShort(); + final int status = reader.readByte(); + final int response = reader.readByte(); + final int fileSize = reader.readInt(); + + return new DownloadRequestResponseMessage(status, response, fileSize); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FileReadyMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FileReadyMessage.java new file mode 100644 index 000000000..62c3df7a7 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FileReadyMessage.java @@ -0,0 +1,44 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +public class FileReadyMessage { + public static final int TRIGGER_MANUAL = 0; + public static final int TRIGGER_AUTOMATIC = 1; + + public final int fileIndex; + public final int dataType; + public final int fileSubtype; + public final int fileNumber; + public final int specificFileFlags; + public final int generalFileFlags; + public final int fileSize; + public final int fileDate; + public final int triggerMethod; + + public FileReadyMessage(int fileIndex, int dataType, int fileSubtype, int fileNumber, int specificFileFlags, int generalFileFlags, int fileSize, int fileDate, int triggerMethod) { + this.fileIndex = fileIndex; + this.dataType = dataType; + this.fileSubtype = fileSubtype; + this.fileNumber = fileNumber; + this.specificFileFlags = specificFileFlags; + this.generalFileFlags = generalFileFlags; + this.fileSize = fileSize; + this.fileDate = fileDate; + this.triggerMethod = triggerMethod; + } + + public static FileReadyMessage parsePacket(byte[] packet) { + final MessageReader reader = new MessageReader(packet, 4); + + final int fileIndex = reader.readShort(); + final int dataType = reader.readByte(); + final int fileSubtype = reader.readByte(); + final int fileNumber = reader.readShort(); + final int specificFileFlags = reader.readByte(); + final int generalFileFlags = reader.readByte(); + final int fileSize = reader.readInt(); + final int fileDate = reader.readInt(); + final int triggerMethod = reader.readByte(); + + return new FileReadyMessage(fileIndex, dataType, fileSubtype, fileNumber, specificFileFlags, generalFileFlags, fileSize, fileDate, triggerMethod); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FileTransferDataMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FileTransferDataMessage.java new file mode 100644 index 000000000..d68ab1286 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FileTransferDataMessage.java @@ -0,0 +1,46 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; + +public class FileTransferDataMessage { + public final int flags; + public final int crc; + public final int dataOffset; + public final byte[] data; + + public final byte[] packet; + + public FileTransferDataMessage(int flags, int crc, int dataOffset, byte[] data) { + this.flags = flags; + this.crc = crc; + this.dataOffset = dataOffset; + this.data = data; + + final MessageWriter writer = new MessageWriter(); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(VivomoveConstants.MESSAGE_FILE_TRANSFER_DATA); + writer.writeByte(flags); + writer.writeShort(crc); + writer.writeInt(dataOffset); + writer.writeBytes(data); + writer.writeShort(0); // CRC will be filled below + final byte[] packet = writer.getBytes(); + BinaryUtils.writeShort(packet, 0, packet.length); + BinaryUtils.writeShort(packet, packet.length - 2, ChecksumCalculator.computeCrc(packet, 0, packet.length - 2)); + this.packet = packet; + } + + public static FileTransferDataMessage parsePacket(byte[] packet) { + final MessageReader reader = new MessageReader(packet, 4); + + final int flags = reader.readByte(); + final int crc = reader.readShort(); + final int dataOffset = reader.readInt(); + final int dataSize = packet.length - 13; + final byte[] data = reader.readBytes(dataSize); + + return new FileTransferDataMessage(flags, crc, dataOffset, data); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FileTransferDataResponseMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FileTransferDataResponseMessage.java new file mode 100644 index 000000000..484e3a989 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FileTransferDataResponseMessage.java @@ -0,0 +1,48 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; + +public class FileTransferDataResponseMessage { + public static final byte RESPONSE_TRANSFER_SUCCESSFUL = 0; + public static final byte RESPONSE_RESEND_LAST_DATA_PACKET = 1; + public static final byte RESPONSE_ABORT_DOWNLOAD_REQUEST = 2; + public static final byte RESPONSE_ERROR_CRC_MISMATCH = 3; + public static final byte RESPONSE_ERROR_DATA_OFFSET_MISMATCH = 4; + public static final byte RESPONSE_SILENT_SYNC_PAUSED = 5; + + public final int status; + public final int response; + public final int nextDataOffset; + + public final byte[] packet; + + public FileTransferDataResponseMessage(int status, int response, int nextDataOffset) { + this.status = status; + this.response = response; + this.nextDataOffset = nextDataOffset; + + final MessageWriter writer = new MessageWriter(); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(VivomoveConstants.MESSAGE_RESPONSE); + writer.writeShort(VivomoveConstants.MESSAGE_FILE_TRANSFER_DATA); + writer.writeByte(status); + writer.writeByte(response); + writer.writeInt(nextDataOffset); + writer.writeShort(0); // CRC will be filled below + final byte[] packet = writer.getBytes(); + BinaryUtils.writeShort(packet, 0, packet.length); + BinaryUtils.writeShort(packet, packet.length - 2, ChecksumCalculator.computeCrc(packet, 0, packet.length - 2)); + this.packet = packet; + } + + public static FileTransferDataResponseMessage parsePacket(byte[] packet) { + final MessageReader reader = new MessageReader(packet, 6); + final int status = reader.readByte(); + final int response = reader.readByte(); + final int nextDataOffset = reader.readInt(); + + return new FileTransferDataResponseMessage(status, response, nextDataOffset); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FindMyPhoneRequestMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FindMyPhoneRequestMessage.java new file mode 100644 index 000000000..fb03e29c4 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FindMyPhoneRequestMessage.java @@ -0,0 +1,17 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +public class FindMyPhoneRequestMessage { + public final int duration; + + public FindMyPhoneRequestMessage(int duration) { + this.duration = duration; + } + + public static FindMyPhoneRequestMessage parsePacket(byte[] packet) { + final MessageReader reader = new MessageReader(packet, 4); + + final int duration = reader.readByte(); + + return new FindMyPhoneRequestMessage(duration); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FitDataMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FitDataMessage.java new file mode 100644 index 000000000..9c529d73b --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FitDataMessage.java @@ -0,0 +1,24 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit.FitMessage; + +public class FitDataMessage { + public final byte[] packet; + + public FitDataMessage(FitMessage... messages) { + final MessageWriter writer = new MessageWriter(); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(VivomoveConstants.MESSAGE_FIT_DATA); + for (FitMessage message : messages) { + message.writeToMessage(writer); + } + writer.writeShort(0); // CRC will be filled below + final byte[] packet = writer.getBytes(); + BinaryUtils.writeShort(packet, 0, packet.length); + BinaryUtils.writeShort(packet, packet.length - 2, ChecksumCalculator.computeCrc(packet, 0, packet.length - 2)); + this.packet = packet; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FitDataResponseMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FitDataResponseMessage.java new file mode 100644 index 000000000..870b15c5e --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FitDataResponseMessage.java @@ -0,0 +1,27 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +public class FitDataResponseMessage { + public final int requestID; + public final int status; + public final int fitResponse; + + public FitDataResponseMessage(int requestID, int status, int fitResponse) { + this.requestID = requestID; + this.status = status; + this.fitResponse = fitResponse; + } + + public static FitDataResponseMessage parsePacket(byte[] packet) { + final MessageReader reader = new MessageReader(packet, 4); + final int requestID = reader.readShort(); + final int status = reader.readByte(); + final int fitResponse = reader.readByte(); + + return new FitDataResponseMessage(requestID, status, fitResponse); + } + + public static final int RESPONSE_APPLIED = 0; + public static final int RESPONSE_NO_DEFINITION = 1; + public static final int RESPONSE_MISMATCH = 2; + public static final int RESPONSE_NOT_READY = 3; +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FitDefinitionMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FitDefinitionMessage.java new file mode 100644 index 000000000..c0e780d42 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FitDefinitionMessage.java @@ -0,0 +1,24 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.fit.FitMessageDefinition; + +public class FitDefinitionMessage { + public final byte[] packet; + + public FitDefinitionMessage(FitMessageDefinition... definitions) { + final MessageWriter writer = new MessageWriter(); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(VivomoveConstants.MESSAGE_FIT_DEFINITION); + for (FitMessageDefinition definition : definitions) { + definition.writeToMessage(writer); + } + writer.writeShort(0); // CRC will be filled below + final byte[] packet = writer.getBytes(); + BinaryUtils.writeShort(packet, 0, packet.length); + BinaryUtils.writeShort(packet, packet.length - 2, ChecksumCalculator.computeCrc(packet, 0, packet.length - 2)); + this.packet = packet; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FitDefinitionResponseMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FitDefinitionResponseMessage.java new file mode 100644 index 000000000..15a86f22f --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/FitDefinitionResponseMessage.java @@ -0,0 +1,27 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +public class FitDefinitionResponseMessage { + public final int requestID; + public final int status; + public final int fitResponse; + + public FitDefinitionResponseMessage(int requestID, int status, int fitResponse) { + this.requestID = requestID; + this.status = status; + this.fitResponse = fitResponse; + } + + public static FitDefinitionResponseMessage parsePacket(byte[] packet) { + final MessageReader reader = new MessageReader(packet, 4); + final int requestID = reader.readShort(); + final int status = reader.readByte(); + final int fitResponse = reader.readByte(); + + return new FitDefinitionResponseMessage(requestID, status, fitResponse); + } + + public static final int RESPONSE_APPLIED = 0; + public static final int RESPONSE_NOT_UNIQUE = 1; + public static final int RESPONSE_OUT_OF_RANGE = 2; + public static final int RESPONSE_NOT_READY = 3; +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/GenericResponseMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/GenericResponseMessage.java new file mode 100644 index 000000000..8dd1c1af9 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/GenericResponseMessage.java @@ -0,0 +1,22 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; + +public class GenericResponseMessage { + public final byte[] packet; + + public GenericResponseMessage(int originalRequestID, int status) { + final MessageWriter writer = new MessageWriter(9); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(VivomoveConstants.MESSAGE_RESPONSE); + writer.writeShort(originalRequestID); + writer.writeByte(status); + writer.writeShort(0); // CRC will be filled below + final byte[] packet = writer.getBytes(); + BinaryUtils.writeShort(packet, 0, packet.length); + BinaryUtils.writeShort(packet, packet.length - 2, ChecksumCalculator.computeCrc(packet, 0, packet.length - 2)); + this.packet = packet; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/GncsControlPointMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/GncsControlPointMessage.java new file mode 100644 index 000000000..9abf1dad1 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/GncsControlPointMessage.java @@ -0,0 +1,17 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs.AncsControlCommand; + +public class GncsControlPointMessage { + public final AncsControlCommand command; + + public GncsControlPointMessage(AncsControlCommand command) { + this.command = command; + } + + public static GncsControlPointMessage parsePacket(byte[] packet) { + final AncsControlCommand command = AncsControlCommand.parseCommand(packet, 4, packet.length - 6); + if (command == null) return null; + return new GncsControlPointMessage(command); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/GncsControlPointResponseMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/GncsControlPointResponseMessage.java new file mode 100644 index 000000000..f3cd4264f --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/GncsControlPointResponseMessage.java @@ -0,0 +1,34 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; + +public class GncsControlPointResponseMessage { + public static final int RESPONSE_SUCCESSFUL = 0; + public static final int RESPONSE_ANCS_ERROR_OCCURRED = 1; + public static final int RESPONSE_INVALID_PARAMETERS = 2; + + public static final int ANCS_ERROR_NO_ERROR = 0; + public static final int ANCS_ERROR_UNKNOWN_ANCS_COMMAND = 0xA0; + public static final int ANCS_ERROR_INVALID_ANCS_COMMAND = 0xA1; + public static final int ANCS_ERROR_INVALID_ANCS_PARAMETER = 0xA2; + public static final int ANCS_ERROR_ACTION_FAILED = 0xA3; + + public final byte[] packet; + + public GncsControlPointResponseMessage(int status, int response, int ancsError) { + final MessageWriter writer = new MessageWriter(11); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(VivomoveConstants.MESSAGE_RESPONSE); + writer.writeShort(VivomoveConstants.MESSAGE_GNCS_CONTROL_POINT_REQUEST); + writer.writeByte(status); + writer.writeByte(response); + writer.writeByte(ancsError); + writer.writeShort(0); // CRC will be filled below + final byte[] packet = writer.getBytes(); + BinaryUtils.writeShort(packet, 0, packet.length); + BinaryUtils.writeShort(packet, packet.length - 2, ChecksumCalculator.computeCrc(packet, 0, packet.length - 2)); + this.packet = packet; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/GncsDataSourceMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/GncsDataSourceMessage.java new file mode 100644 index 000000000..acb4c7e65 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/GncsDataSourceMessage.java @@ -0,0 +1,25 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; + +public class GncsDataSourceMessage { + public final byte[] packet; + + public GncsDataSourceMessage(byte[] ancsMessage, int dataOffset, int size) { + final MessageWriter writer = new MessageWriter(); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(VivomoveConstants.MESSAGE_GNCS_DATA_SOURCE); + writer.writeShort(ancsMessage.length); + // TODO: CRC Seed! + writer.writeShort(ChecksumCalculator.computeCrc(ancsMessage, dataOffset, size)); + writer.writeShort(dataOffset); + writer.writeBytes(ancsMessage, dataOffset, size); + writer.writeShort(0); // CRC will be filled below + final byte[] packet = writer.getBytes(); + BinaryUtils.writeShort(packet, 0, packet.length); + BinaryUtils.writeShort(packet, packet.length - 2, ChecksumCalculator.computeCrc(packet, 0, packet.length - 2)); + this.packet = packet; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/GncsDataSourceResponseMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/GncsDataSourceResponseMessage.java new file mode 100644 index 000000000..f25e71d99 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/GncsDataSourceResponseMessage.java @@ -0,0 +1,26 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +public class GncsDataSourceResponseMessage { + public static final int RESPONSE_TRANSFER_SUCCESSFUL = 0; + public static final int RESPONSE_RESEND_LAST_DATA_PACKET = 1; + public static final int RESPONSE_ABORT_REQUEST = 2; + public static final int RESPONSE_ERROR_CRC_MISMATCH = 3; + public static final int RESPONSE_ERROR_DATA_OFFSET_MISMATCH = 4; + + public final int status; + public final int response; + + public GncsDataSourceResponseMessage(int status, int response) { + this.status = status; + this.response = response; + } + + public static GncsDataSourceResponseMessage parsePacket(byte[] packet) { + final MessageReader reader = new MessageReader(packet, 4); + final int requestMessageID = reader.readShort(); + final int status = reader.readByte(); + final int response = reader.readByte(); + + return new GncsDataSourceResponseMessage(status, response); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/GncsNotificationSourceMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/GncsNotificationSourceMessage.java new file mode 100644 index 000000000..d0a76ffc1 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/GncsNotificationSourceMessage.java @@ -0,0 +1,35 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs.AncsCategory; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs.AncsEvent; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs.AncsEventFlag; +import org.apache.commons.lang3.EnumUtils; + +import java.util.Set; + +public class GncsNotificationSourceMessage { + public final byte[] packet; + + public GncsNotificationSourceMessage(AncsEvent event, Set eventFlags, AncsCategory category, int categoryCount, int notificationUID) { + final MessageWriter writer = new MessageWriter(15); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(VivomoveConstants.MESSAGE_NOTIFICATION_SOURCE); + + writer.writeByte(event.ordinal()); + writer.writeByte(eventFlags == null ? 26 : ((int) EnumUtils.generateBitVector(AncsEventFlag.class, eventFlags))); + writer.writeByte(category.ordinal()); + writer.writeByte(Math.min(categoryCount, 127)); + writer.writeInt(notificationUID); + // TODO: Extra flags? + writer.writeByte(3); + + writer.writeShort(0); // CRC will be filled below + final byte[] packet = writer.getBytes(); + BinaryUtils.writeShort(packet, 0, packet.length); + BinaryUtils.writeShort(packet, packet.length - 2, ChecksumCalculator.computeCrc(packet, 0, packet.length - 2)); + this.packet = packet; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/MessageReader.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/MessageReader.java new file mode 100644 index 000000000..06c51ce1b --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/MessageReader.java @@ -0,0 +1,84 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; + +import java.nio.charset.StandardCharsets; + +public class MessageReader { + private final byte[] data; + private int position; + + public MessageReader(byte[] data) { + this.data = data; + } + + public MessageReader(byte[] data, int skipOffset) { + this.data = data; + this.position = skipOffset; + } + + public boolean isEof() { + return position >= data.length; + } + + public int getPosition() { + return position; + } + + public void skip(int offset) { + if (position + offset > data.length) throw new IllegalStateException(); + position += offset; + } + + public int readByte() { + if (position + 1 > data.length) throw new IllegalStateException(); + final int result = BinaryUtils.readByte(data, position); + ++position; + return result; + } + + public int readShort() { + if (position + 2 > data.length) throw new IllegalStateException(); + final int result = BinaryUtils.readShort(data, position); + position += 2; + return result; + } + + public int readInt() { + if (position + 4 > data.length) throw new IllegalStateException(); + final int result = BinaryUtils.readInt(data, position); + position += 4; + return result; + } + + public long readLong() { + if (position + 8 > data.length) throw new IllegalStateException(); + final long result = BinaryUtils.readLong(data, position); + position += 8; + return result; + } + + public String readString() { + final int size = readByte(); + if (position + size > data.length) throw new IllegalStateException(); + final String result = new String(data, position, size, StandardCharsets.UTF_8); + position += size; + return result; + } + + public byte[] readBytes(int size) { + if (position + size > data.length) throw new IllegalStateException(); + final byte[] result = new byte[size]; + System.arraycopy(data, position, result, 0, size); + position += size; + return result; + } + + public byte[] readBytesTo(int size, byte[] buffer, int offset) { + if (offset + size > buffer.length) throw new IllegalArgumentException(); + if (position + size > data.length) throw new IllegalStateException(); + System.arraycopy(data, position, buffer, offset, size); + position += size; + return buffer; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/MessageWriter.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/MessageWriter.java new file mode 100644 index 000000000..070414459 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/MessageWriter.java @@ -0,0 +1,77 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +public class MessageWriter { + private static final int DEFAULT_BUFFER_SIZE = 16384; + + private final byte[] buffer; + private int position; + + public MessageWriter() { + this(DEFAULT_BUFFER_SIZE); + } + + public MessageWriter(int bufferSize) { + this.buffer = new byte[bufferSize]; + } + + public void writeByte(int value) { + if (position + 1 > buffer.length) throw new IllegalStateException(); + BinaryUtils.writeByte(buffer, position, value); + ++position; + } + + public void writeShort(int value) { + if (position + 2 > buffer.length) throw new IllegalStateException(); + BinaryUtils.writeShort(buffer, position, value); + position += 2; + } + + public void writeInt(int value) { + if (position + 4 > buffer.length) throw new IllegalStateException(); + BinaryUtils.writeInt(buffer, position, value); + position += 4; + } + + public void writeLong(long value) { + if (position + 8 > buffer.length) throw new IllegalStateException(); + BinaryUtils.writeLong(buffer, position, value); + position += 8; + } + + 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 (position + 1 + size > buffer.length) throw new IllegalStateException(); + writeByte(size); + System.arraycopy(bytes, 0, buffer, position, size); + position += size; + } + + public byte[] getBytes() { + return position == buffer.length ? buffer : Arrays.copyOf(buffer, position); + } + + public byte[] peekBytes() { + return buffer; + } + + public int getSize() { + return position; + } + + public void writeBytes(byte[] bytes) { + writeBytes(bytes, 0, bytes.length); + } + + public void writeBytes(byte[] bytes, int offset, int size) { + if (position + size > buffer.length) throw new IllegalStateException(); + System.arraycopy(bytes, offset, buffer, position, size); + position += size; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/MusicControlCapabilitiesMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/MusicControlCapabilitiesMessage.java new file mode 100644 index 000000000..7487c75a8 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/MusicControlCapabilitiesMessage.java @@ -0,0 +1,16 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +public class MusicControlCapabilitiesMessage { + public final int supportedCapabilities; + + public MusicControlCapabilitiesMessage(int supportedCapabilities) { + this.supportedCapabilities = supportedCapabilities; + } + + public static MusicControlCapabilitiesMessage parsePacket(byte[] packet) { + final MessageReader reader = new MessageReader(packet, 4); + final int supportedCapabilities = reader.readByte(); + + return new MusicControlCapabilitiesMessage(supportedCapabilities); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/MusicControlCapabilitiesResponseMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/MusicControlCapabilitiesResponseMessage.java new file mode 100644 index 000000000..e536f1d67 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/MusicControlCapabilitiesResponseMessage.java @@ -0,0 +1,28 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.GarminMusicControlCommand; + +public class MusicControlCapabilitiesResponseMessage { + public final byte[] packet; + + public MusicControlCapabilitiesResponseMessage(int status, GarminMusicControlCommand[] commands) { + if (commands.length > 255) throw new IllegalArgumentException("Too many supported commands"); + final MessageWriter writer = new MessageWriter(); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(VivomoveConstants.MESSAGE_RESPONSE); + writer.writeShort(VivomoveConstants.MESSAGE_MUSIC_CONTROL_CAPABILITIES); + writer.writeByte(status); + writer.writeByte(commands.length); + for (GarminMusicControlCommand command : commands) { + writer.writeByte(command.ordinal()); + } + writer.writeShort(0); // CRC will be filled below + final byte[] packet = writer.getBytes(); + BinaryUtils.writeShort(packet, 0, packet.length); + BinaryUtils.writeShort(packet, packet.length - 2, ChecksumCalculator.computeCrc(packet, 0, packet.length - 2)); + this.packet = packet; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/MusicControlEntityUpdateMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/MusicControlEntityUpdateMessage.java new file mode 100644 index 000000000..c88f440d4 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/MusicControlEntityUpdateMessage.java @@ -0,0 +1,24 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ams.AmsEntityAttribute; + +public class MusicControlEntityUpdateMessage { + public final byte[] packet; + + public MusicControlEntityUpdateMessage(AmsEntityAttribute... attributes) { + final MessageWriter writer = new MessageWriter(); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(VivomoveConstants.MESSAGE_MUSIC_CONTROL_ENTITY_UPDATE); + for (AmsEntityAttribute attribute : attributes) { + attribute.writeToMessage(writer); + } + writer.writeShort(0); // CRC will be filled below + final byte[] packet = writer.getBytes(); + BinaryUtils.writeShort(packet, 0, packet.length); + BinaryUtils.writeShort(packet, packet.length - 2, ChecksumCalculator.computeCrc(packet, 0, packet.length - 2)); + this.packet = packet; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/NOTES.txt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/NOTES.txt new file mode 100644 index 000000000..45a370628 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/NOTES.txt @@ -0,0 +1,144 @@ +Generic message structure: + + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Size | Message ID | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| | ++ + +| Payload... | ++ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| | CRC | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + + +Generic response message (MESSAGE_RESPONSE) structure: + + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Size | Message ID=5000 | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Request Message ID | Status | | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + +| | ++ + +| Payload... | ++ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| | CRC | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + + +MESSAGE_DEVICE_INFORMATION: + + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Size | Message ID=5024 | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Protocol Version | Product Number | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Unit Number | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Software Version | Max Packet Size | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +|Bluetooth Name.| | ++-+-+-+-+-+-+-+-+ + +| Bluetooth Name UTF-8... | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +|Device Name Le.| | ++-+-+-+-+-+-+-+-+ + +| Device Name UTF-8... | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +|Device Model L.| | ++-+-+-+-+-+-+-+-+ + +| Device Model UTF-8... | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| CRC | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + + +MESSAGE_BATTERY_STATUS: + + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Size | Message ID=5023 | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +|unknown| OK|un.| Voltage | Capacity | Remaining Time| ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| CRC | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + + +MESSAGE_DEVICE_SETTINGS: + + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Size | Message ID=5026 | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Settings Count| Setting Type | Setting Size | | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + +| Setting Bytes... | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Setting Type 2| Setting Size 2| | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + +| Setting Bytes 2... | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| CRC | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + + +MESSAGE_PROTOBUF_REQUEST: + + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Size | Message ID=5043 | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Request ID | Data Offset | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| | Total Protobuf Length | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| | Protobuf Data Length | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| | | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + +| Protobuf Payload... | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| CRC | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + + +MESSAGE_PROTOBUF_REQUEST response: + + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Size | Message ID=5000 | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Request Message ID=5043 | Status | Request ID | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| | Data Offset | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| | Status | Error | CRC | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| | ++-+-+-+-+-+-+-+-+ + + +MESSAGE_CONFIGURATION: + + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Size | Message ID=5050 | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +|Bitflags Length| Capability Bitflags... | ++-+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| | CRC | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + + diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/NotificationServiceSubscriptionMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/NotificationServiceSubscriptionMessage.java new file mode 100644 index 000000000..aae890536 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/NotificationServiceSubscriptionMessage.java @@ -0,0 +1,32 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +public class NotificationServiceSubscriptionMessage { + public static final int INTENT_UNSUBSCRIBE = 0; + public static final int INTENT_SUBSCRIBE = 1; + private static final int FEATURE_FLAG_PHONE_NUMBER = 1; + + public final int intentIndicator; + public final int featureFlags; + + public NotificationServiceSubscriptionMessage(int intentIndicator, int featureFlags) { + this.intentIndicator = intentIndicator; + this.featureFlags = featureFlags; + } + + public boolean isSubscribe() { + return intentIndicator == INTENT_SUBSCRIBE; + } + + public boolean hasPhoneNumberSupport() { + return (featureFlags & FEATURE_FLAG_PHONE_NUMBER) != 0; + } + + public static NotificationServiceSubscriptionMessage parsePacket(byte[] packet) { + final MessageReader reader = new MessageReader(packet, 4); + + final int intentIndicator = reader.readByte(); + final int featureFlags = packet.length > 7 ? reader.readByte() : 0; + + return new NotificationServiceSubscriptionMessage(intentIndicator, featureFlags); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/NotificationServiceSubscriptionResponseMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/NotificationServiceSubscriptionResponseMessage.java new file mode 100644 index 000000000..a186fed3d --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/NotificationServiceSubscriptionResponseMessage.java @@ -0,0 +1,25 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; + +public class NotificationServiceSubscriptionResponseMessage { + public final byte[] packet; + + public NotificationServiceSubscriptionResponseMessage(int status, int response, int intent, int featureFlags) { + final MessageWriter writer = new MessageWriter(12); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(VivomoveConstants.MESSAGE_RESPONSE); + writer.writeShort(VivomoveConstants.MESSAGE_NOTIFICATION_SERVICE_SUBSCRIPTION); + writer.writeByte(status); + writer.writeByte(response); + writer.writeByte(intent); + writer.writeByte(featureFlags); + writer.writeShort(0); // CRC will be filled below + final byte[] packet = writer.getBytes(); + BinaryUtils.writeShort(packet, 0, packet.length); + BinaryUtils.writeShort(packet, packet.length - 2, ChecksumCalculator.computeCrc(packet, 0, packet.length - 2)); + this.packet = packet; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/ProtobufRequestMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/ProtobufRequestMessage.java new file mode 100644 index 000000000..eeef471bb --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/ProtobufRequestMessage.java @@ -0,0 +1,46 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; + +public class ProtobufRequestMessage { + public final byte[] packet; + public final int requestId; + public final int dataOffset; + public final int totalProtobufLength; + public final int protobufDataLength; + public final byte[] messageBytes; + + public ProtobufRequestMessage(int requestId, int dataOffset, int totalProtobufLength, int protobufDataLength, byte[] messageBytes) { + this.requestId = requestId; + this.dataOffset = dataOffset; + this.totalProtobufLength = totalProtobufLength; + this.protobufDataLength = protobufDataLength; + this.messageBytes = messageBytes; + + final MessageWriter writer = new MessageWriter(); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(VivomoveConstants.MESSAGE_PROTOBUF_REQUEST); + writer.writeShort(requestId); + writer.writeInt(dataOffset); + writer.writeInt(totalProtobufLength); + writer.writeInt(protobufDataLength); + writer.writeBytes(messageBytes); + writer.writeShort(0); // CRC will be filled below + final byte[] packet = writer.getBytes(); + BinaryUtils.writeShort(packet, 0, packet.length); + BinaryUtils.writeShort(packet, packet.length - 2, ChecksumCalculator.computeCrc(packet, 0, packet.length - 2)); + this.packet = packet; + } + + public static ProtobufRequestMessage parsePacket(byte[] packet) { + final MessageReader reader = new MessageReader(packet, 4); + 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); + return new ProtobufRequestMessage(requestID, dataOffset, totalProtobufLength, protobufDataLength, messageBytes); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/ProtobufRequestResponseMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/ProtobufRequestResponseMessage.java new file mode 100644 index 000000000..406281457 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/ProtobufRequestResponseMessage.java @@ -0,0 +1,37 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +public class ProtobufRequestResponseMessage { + public static final int NO_ERROR = 0; + public static final int UNKNOWN_REQUEST_ID = 100; + public static final int DUPLICATE_PACKET = 101; + public static final int MISSING_PACKET = 102; + public static final int EXCEEDED_TOTAL_PROTOBUF_LENGTH = 103; + public static final int PROTOBUF_PARSE_ERROR = 200; + public static final int UNKNOWN_PROTOBUF_MESSAGE = 201; + + public final int status; + public final int requestId; + public final int dataOffset; + public final int protobufStatus; + public final int error; + + public ProtobufRequestResponseMessage(int status, int requestId, int dataOffset, int protobufStatus, int error) { + this.status = status; + this.requestId = requestId; + this.dataOffset = dataOffset; + this.protobufStatus = protobufStatus; + this.error = error; + } + + public static ProtobufRequestResponseMessage parsePacket(byte[] packet) { + final MessageReader reader = new MessageReader(packet, 4); + final int requestMessageID = reader.readShort(); + final int status = reader.readByte(); + final int requestID = reader.readShort(); + final int dataOffset = reader.readInt(); + final int protobufStatus = reader.readByte(); + final int error = reader.readByte(); + + return new ProtobufRequestResponseMessage(status, requestID, dataOffset, protobufStatus, error); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/RequestMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/RequestMessage.java new file mode 100644 index 000000000..da4d42ee0 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/RequestMessage.java @@ -0,0 +1,24 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.GarminDeviceSetting; + +import java.util.Map; + +public class RequestMessage { + public final byte[] packet; + + public RequestMessage(int requestMessageID) { + final MessageWriter writer = new MessageWriter(8); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(VivomoveConstants.MESSAGE_REQUEST); + writer.writeShort(requestMessageID); + writer.writeShort(0); // CRC will be filled below + final byte[] packet = writer.getBytes(); + BinaryUtils.writeShort(packet, 0, packet.length); + BinaryUtils.writeShort(packet, packet.length - 2, ChecksumCalculator.computeCrc(packet, 0, packet.length - 2)); + this.packet = packet; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/ResponseMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/ResponseMessage.java new file mode 100644 index 000000000..6acca3787 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/ResponseMessage.java @@ -0,0 +1,42 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; + +import java.util.Locale; + +public class ResponseMessage { + public final int requestID; + public final int status; + + public ResponseMessage(int requestID, int status) { + this.requestID = requestID; + this.status = status; + } + + public static ResponseMessage parsePacket(byte[] packet) { + final MessageReader reader = new MessageReader(packet, 4); + final int requestID = reader.readShort(); + final int status = reader.readByte(); + + return new ResponseMessage(requestID, status); + } + + public String getStatusStr() { + switch (status) { + case VivomoveConstants.STATUS_ACK: + return "ACK"; + case VivomoveConstants.STATUS_NAK: + return "NAK"; + case VivomoveConstants.STATUS_UNSUPPORTED: + return "UNSUPPORTED"; + case VivomoveConstants.STATUS_DECODE_ERROR: + return "DECODE ERROR"; + case VivomoveConstants.STATUS_CRC_ERROR: + return "CRC ERROR"; + case VivomoveConstants.STATUS_LENGTH_ERROR: + return "LENGTH ERROR"; + default: + return String.format(Locale.ROOT, "Unknown status %x", status); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/SetDeviceSettingsMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/SetDeviceSettingsMessage.java new file mode 100644 index 000000000..c69e17ad3 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/SetDeviceSettingsMessage.java @@ -0,0 +1,44 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.GarminDeviceSetting; + +import java.util.Map; + +public class SetDeviceSettingsMessage { + public final byte[] packet; + + public SetDeviceSettingsMessage(Map settings) { + final int settingsCount = settings.size(); + if (settingsCount == 0) throw new IllegalArgumentException("Empty settings"); + if (settingsCount > 255) throw new IllegalArgumentException("Too many settings"); + + final MessageWriter writer = new MessageWriter(); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(VivomoveConstants.MESSAGE_DEVICE_SETTINGS); + 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); + } + } + writer.writeShort(0); // CRC will be filled below + final byte[] packet = writer.getBytes(); + BinaryUtils.writeShort(packet, 0, packet.length); + BinaryUtils.writeShort(packet, packet.length - 2, ChecksumCalculator.computeCrc(packet, 0, packet.length - 2)); + this.packet = packet; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/SetDeviceSettingsResponseMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/SetDeviceSettingsResponseMessage.java new file mode 100644 index 000000000..68fd988ee --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/SetDeviceSettingsResponseMessage.java @@ -0,0 +1,20 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +public class SetDeviceSettingsResponseMessage { + public final int status; + public final int response; + + public SetDeviceSettingsResponseMessage(int status, int response) { + this.status = status; + this.response = response; + } + + public static SetDeviceSettingsResponseMessage parsePacket(byte[] packet) { + final MessageReader reader = new MessageReader(packet, 4); + final int requestID = reader.readShort(); + final int status = reader.readByte(); + final int response = reader.readByte(); + + return new SetDeviceSettingsResponseMessage(status, response); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/SupportedFileTypesRequestMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/SupportedFileTypesRequestMessage.java new file mode 100644 index 000000000..40c4045de --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/SupportedFileTypesRequestMessage.java @@ -0,0 +1,20 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; + +public class SupportedFileTypesRequestMessage { + public final byte[] packet; + + public SupportedFileTypesRequestMessage() { + final MessageWriter writer = new MessageWriter(6); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(VivomoveConstants.MESSAGE_SUPPORTED_FILE_TYPES_REQUEST); + writer.writeShort(0); // CRC will be filled below + final byte[] packet = writer.getBytes(); + BinaryUtils.writeShort(packet, 0, packet.length); + BinaryUtils.writeShort(packet, packet.length - 2, ChecksumCalculator.computeCrc(packet, 0, packet.length - 2)); + this.packet = packet; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/SupportedFileTypesResponseMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/SupportedFileTypesResponseMessage.java new file mode 100644 index 000000000..a0c1d8a9e --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/SupportedFileTypesResponseMessage.java @@ -0,0 +1,48 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import java.util.ArrayList; +import java.util.List; + +public class SupportedFileTypesResponseMessage { + public static final int FILE_DATA_TYPE_FIT = 128; + public static final int FILE_DATA_TYPE_GRAPHIC = 2; + public static final int FILE_DATA_TYPE_INVALID = -1; + public static final int FILE_DATA_TYPE_NON_FIT = 255; + + public final int status; + public final List fileTypes; + + public SupportedFileTypesResponseMessage(int status, List fileTypes) { + this.status = status; + this.fileTypes = fileTypes; + } + + public static SupportedFileTypesResponseMessage parsePacket(byte[] packet) { + final MessageReader reader = new MessageReader(packet, 4); + final int requestID = reader.readShort(); + final int status = reader.readByte(); + + final int typeCount = reader.readByte(); + final List types = new ArrayList<>(typeCount); + for (int i = 0; i < typeCount; ++i) { + final int fileDataType = reader.readByte(); + final int fileSubType = reader.readByte(); + final String garminDeviceFileType = reader.readString(); + types.add(new FileTypeInfo(fileDataType, fileSubType, garminDeviceFileType)); + } + + return new SupportedFileTypesResponseMessage(status, types); + } + + public static class FileTypeInfo { + public final int fileDataType; + public final int fileSubType; + public final String garminDeviceFileType; + + public FileTypeInfo(int fileDataType, int fileSubType, String garminDeviceFileType) { + this.fileDataType = fileDataType; + this.fileSubType = fileSubType; + this.garminDeviceFileType = garminDeviceFileType; + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/SyncRequestMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/SyncRequestMessage.java new file mode 100644 index 000000000..94bbd3931 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/SyncRequestMessage.java @@ -0,0 +1,45 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.GarminMessageType; +import nodomain.freeyourgadget.gadgetbridge.util.GB; + +import java.util.HashSet; +import java.util.Set; + +public class SyncRequestMessage { + public static final int OPTION_MANUAL = 0; + public static final int OPTION_INVISIBLE = 1; + public static final int OPTION_VISIBLE_AS_NEEDED = 2; + + public final int option; + public final Set fileTypes; + + public SyncRequestMessage(int option, Set fileTypes) { + this.option = option; + this.fileTypes = fileTypes; + } + + public static SyncRequestMessage parsePacket(byte[] packet) { + final MessageReader reader = new MessageReader(packet, 4); + final int option = reader.readByte(); + final int bitMaskSize = reader.readByte(); + final byte[] longBits = reader.readBytesTo(bitMaskSize, new byte[8], 0); + long bitMask = BinaryUtils.readLong(longBits, 0); + + final Set fileTypes = new HashSet<>(GarminMessageType.values().length); + for (GarminMessageType messageType : GarminMessageType.values()) { + int num = messageType.ordinal(); + long mask = 1L << num; + if ((bitMask & mask) != 0) { + fileTypes.add(messageType); + bitMask &= ~mask; + } + } + if (bitMask != 0) { + throw new IllegalArgumentException("Unknown bit mask " + GB.hexdump(longBits, 0, longBits.length)); + } + + return new SyncRequestMessage(option, fileTypes); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/SystemEventMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/SystemEventMessage.java new file mode 100644 index 000000000..f1ec7ea98 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/SystemEventMessage.java @@ -0,0 +1,29 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.GarminSystemEventType; + +public class SystemEventMessage { + public final byte[] packet; + + public SystemEventMessage(GarminSystemEventType eventType, Object value) { + final MessageWriter writer = new MessageWriter(); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(VivomoveConstants.MESSAGE_SYSTEM_EVENT); + 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); + } + writer.writeShort(0); // CRC will be filled below + final byte[] packet = writer.getBytes(); + BinaryUtils.writeShort(packet, 0, packet.length); + BinaryUtils.writeShort(packet, packet.length - 2, ChecksumCalculator.computeCrc(packet, 0, packet.length - 2)); + this.packet = packet; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/SystemEventResponseMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/SystemEventResponseMessage.java new file mode 100644 index 000000000..534beefd5 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/SystemEventResponseMessage.java @@ -0,0 +1,20 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +public class SystemEventResponseMessage { + public final int status; + public final int response; + + public SystemEventResponseMessage(int status, int response) { + this.status = status; + this.response = response; + } + + public static SystemEventResponseMessage parsePacket(byte[] packet) { + final MessageReader reader = new MessageReader(packet, 4); + final int requestID = reader.readShort(); + final int status = reader.readByte(); + final int response = reader.readByte(); + + return new SystemEventResponseMessage(status, response); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/UploadRequestMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/UploadRequestMessage.java new file mode 100644 index 000000000..730398029 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/UploadRequestMessage.java @@ -0,0 +1,24 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; + +public class UploadRequestMessage { + public final byte[] packet; + + public UploadRequestMessage(int fileIndex, int dataOffset, int maxSize, int crcSeed) { + final MessageWriter writer = new MessageWriter(18); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(VivomoveConstants.MESSAGE_UPLOAD_REQUEST); + writer.writeShort(fileIndex); + writer.writeInt(maxSize); + writer.writeInt(dataOffset); + writer.writeShort(crcSeed); + writer.writeShort(0); // CRC will be filled below + final byte[] packet = writer.getBytes(); + BinaryUtils.writeShort(packet, 0, packet.length); + BinaryUtils.writeShort(packet, packet.length - 2, ChecksumCalculator.computeCrc(packet, 0, packet.length - 2)); + this.packet = packet; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/UploadRequestResponseMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/UploadRequestResponseMessage.java new file mode 100644 index 000000000..53b9a192b --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/UploadRequestResponseMessage.java @@ -0,0 +1,36 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +public class UploadRequestResponseMessage { + public static final byte RESPONSE_UPLOAD_REQUEST_OKAY = 0; + public static final byte RESPONSE_DATA_FILE_INDEX_DOES_NOT_EXIST = 1; + public static final byte RESPONSE_DATA_FILE_INDEX_EXISTS_BUT_IS_NOT_WRITEABLE = 2; + public static final byte RESPONSE_NOT_ENOUGH_SPACE_TO_COMPLETE_WRITE = 3; + public static final byte RESPONSE_REQUEST_INVALID = 4; + public static final byte RESPONSE_NOT_READY_TO_UPLOAD = 5; + public static final byte RESPONSE_CRC_INCORRECT = 6; + + public final int status; + public final int response; + public final int dataOffset; + public final int maxFileSize; + public final int crcSeed; + + public UploadRequestResponseMessage(int status, int response, int dataOffset, int maxFileSize, int crcSeed) { + this.status = status; + this.response = response; + this.dataOffset = dataOffset; + this.maxFileSize = maxFileSize; + this.crcSeed = crcSeed; + } + + public static UploadRequestResponseMessage parsePacket(byte[] packet) { + final MessageReader reader = new MessageReader(packet, 6); + final int status = reader.readByte(); + final int response = reader.readByte(); + final int dataOffset = reader.readInt(); + final int maxFileSize = reader.readInt(); + final int crcSeed = reader.readInt(); + + return new UploadRequestResponseMessage(status, response, dataOffset, maxFileSize, crcSeed); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/WeatherRequestMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/WeatherRequestMessage.java new file mode 100644 index 000000000..16195fd5a --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/WeatherRequestMessage.java @@ -0,0 +1,25 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +public class WeatherRequestMessage { + public final int format; + public final int latitude; + public final int longitude; + public final int hoursOfForecast; + + public WeatherRequestMessage(int format, int latitude, int longitude, int hoursOfForecast) { + this.format = format; + this.latitude = latitude; + this.longitude = longitude; + this.hoursOfForecast = hoursOfForecast; + } + + public static WeatherRequestMessage parsePacket(byte[] packet) { + final MessageReader reader = new MessageReader(packet, 4); + final int format = reader.readByte(); + final int latitude = reader.readInt(); + final int longitude = reader.readInt(); + final int hoursOfForecast = reader.readByte(); + + return new WeatherRequestMessage(format, latitude, longitude, hoursOfForecast); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/WeatherRequestResponseMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/WeatherRequestResponseMessage.java new file mode 100644 index 000000000..848ab81bd --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/messages/WeatherRequestResponseMessage.java @@ -0,0 +1,25 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.BinaryUtils; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; + +public class WeatherRequestResponseMessage { + public final byte[] packet; + + public WeatherRequestResponseMessage(int status, int requestStatus, int totalDataMessages, int timeout) { + final MessageWriter writer = new MessageWriter(); + writer.writeShort(0); // packet size will be filled below + writer.writeShort(VivomoveConstants.MESSAGE_RESPONSE); + writer.writeShort(VivomoveConstants.MESSAGE_WEATHER_REQUEST); + writer.writeByte(status); + writer.writeByte(requestStatus); + writer.writeByte(totalDataMessages); + writer.writeShort(timeout); + writer.writeShort(0); // CRC will be filled below + final byte[] packet = writer.getBytes(); + BinaryUtils.writeShort(packet, 0, packet.length); + BinaryUtils.writeShort(packet, packet.length - 2, ChecksumCalculator.computeCrc(packet, 0, packet.length - 2)); + this.packet = packet; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/notifications/NotificationData.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/notifications/NotificationData.java new file mode 100644 index 000000000..fd86f50a8 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/notifications/NotificationData.java @@ -0,0 +1,150 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.notifications; + +import android.util.SparseIntArray; +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs.AncsAttribute; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs.AncsCategory; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs.AncsEventFlag; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Date; +import java.util.HashSet; +import java.util.Set; + +public class NotificationData { + private static final Logger LOG = LoggerFactory.getLogger(NotificationData.class); + + private static final SparseIntArray POSITIVE_NOTIFICATION_ACTIONS = indexingMap(NotificationSpec.Action.TYPE_SYNTECTIC_OPEN, NotificationSpec.Action.TYPE_WEARABLE_REPLY, NotificationSpec.Action.TYPE_SYNTECTIC_REPLY_PHONENR, NotificationSpec.Action.TYPE_WEARABLE_SIMPLE); + private static final SparseIntArray NEGATIVE_NOTIFICATION_ACTIONS = indexingMap(NotificationSpec.Action.TYPE_SYNTECTIC_DISMISS, NotificationSpec.Action.TYPE_SYNTECTIC_DISMISS_ALL, NotificationSpec.Action.TYPE_SYNTECTIC_MUTE); + + public final NotificationSpec spec; + public final AncsCategory category; + public final Set flags; + public final String title; + public final String subtitle; + public final String message; + public final NotificationSpec.Action positiveAction; + public final NotificationSpec.Action negativeAction; + + public NotificationData(NotificationSpec spec) { + this.spec = spec; + + final AncsCategory category; + final Set flags = new HashSet<>(); + switch (spec.type) { + case GENERIC_SMS: + category = AncsCategory.SMS; + break; + case GENERIC_PHONE: + category = AncsCategory.INCOMING_CALL; + flags.add(AncsEventFlag.IMPORTANT); + break; + case GENERIC_EMAIL: + case GMAIL: + case BBM: + case MAILBOX: + case OUTLOOK: + category = AncsCategory.EMAIL; + break; + case GENERIC_NAVIGATION: + case GOOGLE_MAPS: + category = AncsCategory.LOCATION; + break; + case GENERIC_CALENDAR: + case GENERIC_ALARM_CLOCK: + category = AncsCategory.SCHEDULE; + break; + case FACEBOOK: + case LINKEDIN: + flags.add(AncsEventFlag.SILENT); + category = AncsCategory.SOCIAL; + break; + // TODO: The rest + default: + category = AncsCategory.OTHER; + break; + } + + this.positiveAction = findNotificationAction(spec, POSITIVE_NOTIFICATION_ACTIONS); + this.negativeAction = findNotificationAction(spec, NEGATIVE_NOTIFICATION_ACTIONS); + + if (this.positiveAction != null) flags.add(AncsEventFlag.POSITIVE_ACTION); + if (this.negativeAction != null) flags.add(AncsEventFlag.NEGATIVE_ACTION); + + this.category = category; + this.flags = flags; + + if (!StringUtils.isEmpty(spec.title)) { + this.title = spec.title; + this.subtitle = spec.subject; + this.message = spec.body; + } else if (!StringUtils.isEmpty(spec.subject)) { + this.title = spec.subject; + this.subtitle = null; + this.message = spec.body; + } else if (!StringUtils.isEmpty(spec.body)) { + this.title = spec.body; + this.subtitle = null; + this.message = spec.body; + } else { + // everything empty!?! + this.title = spec.type.name(); + this.subtitle = null; + this.message = spec.sender; + } + } + + public String getAttribute(AncsAttribute attribute) { + switch (attribute) { + case DATE: + final long notificationTimestamp = spec.when == 0 ? System.currentTimeMillis() : spec.when; + return VivomoveConstants.ANCS_DATE_FORMAT.format(new Date(notificationTimestamp)); + case TITLE: + return title; + case SUBTITLE: + return subtitle; + case APP_IDENTIFIER: + return spec.sourceAppId; + case MESSAGE: + return message; + case MESSAGE_SIZE: + return Integer.toString(message.length()); + case POSITIVE_ACTION_LABEL: + return positiveAction == null ? null : positiveAction.title; + case NEGATIVE_ACTION_LABEL: + return negativeAction == null ? null : negativeAction.title; + case PHONE_NUMBER: + return spec.phoneNumber; + default: + LOG.warn("Unknown attribute {}", attribute); + return null; + } + } + + + private static NotificationSpec.Action findNotificationAction(NotificationSpec notificationSpec, SparseIntArray expectedActionsMap) { + if (notificationSpec == null || notificationSpec.attachedActions == null) return null; + + int bestIndex = Integer.MAX_VALUE; + NotificationSpec.Action bestAction = null; + for (final NotificationSpec.Action action : notificationSpec.attachedActions) { + final int index = expectedActionsMap.get(action.type); + if (index > 0 && index < bestIndex) { + bestIndex = index; + bestAction = action; + } + } + return bestAction; + } + + private static SparseIntArray indexingMap(int... data) { + final SparseIntArray result = new SparseIntArray(data.length); + for (int i = 0; i < data.length; ++i) { + result.put(data[i], i + 1); + } + return result; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/notifications/NotificationStorage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/notifications/NotificationStorage.java new file mode 100644 index 000000000..9ee2fc896 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/notifications/NotificationStorage.java @@ -0,0 +1,101 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.notifications; + +import android.util.SparseArray; +import android.util.SparseIntArray; +import android.util.SparseLongArray; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ancs.AncsCategory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +public class NotificationStorage { + private static final Logger LOG = LoggerFactory.getLogger(NotificationStorage.class); + + private static final long EXPIRATION = 30 * 1000; + + private final Object lock = new Object(); + private final SparseArray storage = new SparseArray<>(); + private final LinkedHashMap> expirationQueue = new LinkedHashMap<>(); + private final SparseLongArray expirationForNotification = new SparseLongArray(); + + private final SparseIntArray categoryCounts = new SparseIntArray(AncsCategory.values().length); + + public void registerNewNotification(NotificationData notificationData) { + final long now = System.currentTimeMillis(); + final long expiration = now + EXPIRATION; + + final int notificationId = notificationData.spec.getId(); + + synchronized (lock) { + cleanup(); + storage.put(notificationId, notificationData); + final int category = notificationData.category.ordinal(); + categoryCounts.put(category, categoryCounts.get(category) + 1); + expirationForNotification.put(notificationId, expiration); + + Set expirationSet = expirationQueue.get(expiration); + if (expirationSet == null) { + expirationSet = new HashSet<>(1); + expirationQueue.put(expiration, expirationSet); + } + expirationSet.add(notificationId); + } + } + + public void deleteNotification(int id) { + synchronized (lock) { + final NotificationData notificationData = storage.get(id); + if (notificationData != null) { + storage.delete(id); + final int categoryOrdinal = notificationData.category.ordinal(); + categoryCounts.put(categoryOrdinal, categoryCounts.get(categoryOrdinal) - 1); + } + final long expiration = expirationForNotification.get(id); + final Set expirationSet = expirationQueue.get(expiration); + if (expirationSet != null) { + expirationSet.remove(id); + } + cleanup(); + } + } + + public NotificationData retrieveNotification(int id) { + synchronized (lock) { + cleanup(); + return storage.get(id); + } + } + + public int getCountInCategory(AncsCategory category) { + synchronized (lock) { + return categoryCounts.get(category.ordinal()); + } + } + + private void cleanup() { + final long now = System.currentTimeMillis(); + Set expiredNotifications = null; + for (final Map.Entry> entry : expirationQueue.entrySet()) { + final long expiration = entry.getKey(); + if (expiration > now) break; + + final Set setToExpire = entry.getValue(); + if (expiredNotifications == null) expiredNotifications = new HashSet<>(setToExpire.size()); + + expiredNotifications.addAll(setToExpire); + } + + if (LOG.isDebugEnabled()) { + LOG.debug("{}/{}/{} notification(s) in storage, removing {}", storage.size(), expirationQueue.size(), expirationForNotification.size(), expiredNotifications == null ? 0 : expiredNotifications.size()); + } + if (expiredNotifications == null) return; + + for (final Integer toExpire : expiredNotifications) { + deleteNotification(toExpire); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/uploads/FileUploadQueue.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/uploads/FileUploadQueue.java new file mode 100644 index 000000000..f2e57aa6e --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/vivomovehr/uploads/FileUploadQueue.java @@ -0,0 +1,199 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.uploads; + +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.ChecksumCalculator; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.VivomoveHrCommunicator; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.CreateFileRequestMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.CreateFileResponseMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.DownloadRequestMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.FileTransferDataMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.FileTransferDataResponseMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.UploadRequestMessage; +import nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.messages.UploadRequestResponseMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Arrays; +import java.util.LinkedList; +import java.util.Queue; + +public class FileUploadQueue { + private static final Logger LOG = LoggerFactory.getLogger(FileUploadQueue.class); + private static final int MAX_BLOCK_SIZE = 500; + // TODO: ? + private static final int UPLOAD_FLAGS = 0; + + private final VivomoveHrCommunicator communicator; + + private final Queue queue = new LinkedList<>(); + + private QueueItem currentlyUploadingItem; + private int currentCrc; + private long totalRemainingBytes; + + public FileUploadQueue(VivomoveHrCommunicator communicator) { + this.communicator = communicator; + } + + public void queueCreateFile(int fileSize, int dataType, int subType, int fileIdentifier, String targetPath, byte[] data) { + queue.add(new QueueItem(fileSize, dataType, subType, fileIdentifier, targetPath, data)); + totalRemainingBytes += fileSize; + checkStartNextUpload(); + } + + public void queueUploadFile(int fileSize, int fileIndex, byte[] data) { + queue.add(new QueueItem(fileSize, fileIndex, data)); + totalRemainingBytes += fileSize; + checkStartNextUpload(); + } + + public void cancelAllUploads() { + queue.clear(); + currentlyUploadingItem = null; +// communicator.sendMessage(new FileTransferDataResponseMessage(VivomoveConstants.STATUS_ACK, FileTransferDataResponseMessage.RESPONSE_ABORT_DOWNLOAD_REQUEST, 0).packet); + } + + private boolean checkStartNextUpload() { + if (currentlyUploadingItem != null) { + LOG.debug("Another upload is pending"); + return false; + } + if (queue.isEmpty()) { + LOG.debug("No upload in queue"); + return true; + } + startNextUpload(); + return false; + } + + private void startNextUpload() { + currentlyUploadingItem = queue.remove(); + currentCrc = 0; + if (currentlyUploadingItem.create) { + LOG.info("Requesting creation of '{}' ({}/{}/{}; {} B)", currentlyUploadingItem.targetPath, currentlyUploadingItem.dataType, currentlyUploadingItem.subType, currentlyUploadingItem.fileIdentifier, currentlyUploadingItem.fileSize); + communicator.sendMessage(new CreateFileRequestMessage(currentlyUploadingItem.fileSize, currentlyUploadingItem.dataType, currentlyUploadingItem.subType, currentlyUploadingItem.fileIdentifier, 0, -1, currentlyUploadingItem.targetPath).packet); + } else { + LOG.info("Requesting upload of {} ({} B)", currentlyUploadingItem.fileIndex, currentlyUploadingItem.fileSize); + communicator.sendMessage(new UploadRequestMessage(currentlyUploadingItem.fileIndex, 0, DownloadRequestMessage.REQUEST_NEW_TRANSFER, 0).packet); + } + } + + public void onCreateFileRequestResponse(CreateFileResponseMessage responseMessage) { + if (currentlyUploadingItem == null) { + LOG.error("Create file request response arrived, but nothing is being uploaded"); + return; + } + if (!currentlyUploadingItem.create) { + LOG.error("Create file request response arrived, but nothing should have been created"); + return; + } + + if (responseMessage.status == VivomoveConstants.STATUS_ACK && responseMessage.response == CreateFileResponseMessage.RESPONSE_FILE_CREATED_SUCCESSFULLY) { + LOG.info("Received successful response for create file request of '{}' ({}/{}/{}; {} B) -> #{}", currentlyUploadingItem.targetPath, currentlyUploadingItem.dataType, currentlyUploadingItem.subType, currentlyUploadingItem.fileIdentifier, currentlyUploadingItem.fileSize, responseMessage.fileIndex); + currentlyUploadingItem.fileIndex = responseMessage.fileIndex; + communicator.sendMessage(new UploadRequestMessage(currentlyUploadingItem.fileIndex, 0, DownloadRequestMessage.REQUEST_NEW_TRANSFER, 0).packet); + } else { + LOG.error("Received error response for upload request request of '{}' ({}/{}/{}; {} B): {}, {}", currentlyUploadingItem.targetPath, currentlyUploadingItem.dataType, currentlyUploadingItem.subType, currentlyUploadingItem.fileIdentifier, currentlyUploadingItem.fileSize, responseMessage.status, responseMessage.response); + totalRemainingBytes -= currentlyUploadingItem.fileSize; + currentlyUploadingItem = null; + checkStartNextUpload(); + } + } + + public void onUploadRequestResponse(UploadRequestResponseMessage responseMessage) { + if (currentlyUploadingItem == null) { + LOG.error("Upload request response arrived, but nothing is being uploaded"); + return; + } + + if (responseMessage.status == VivomoveConstants.STATUS_ACK && responseMessage.response == UploadRequestResponseMessage.RESPONSE_UPLOAD_REQUEST_OKAY) { + LOG.info("Received successful response for upload request of {}: {}/{} (max {}B)", currentlyUploadingItem.fileIndex, responseMessage.status, responseMessage.response, responseMessage.maxFileSize); + currentCrc = responseMessage.crcSeed; + } else { + LOG.error("Received error response for upload request of {}: {}/{}", currentlyUploadingItem.fileIndex, responseMessage.status, responseMessage.response); + totalRemainingBytes -= currentlyUploadingItem.fileSize; + currentlyUploadingItem = null; + checkStartNextUpload(); + } + } + + public void onFileTransferResponse(FileTransferDataResponseMessage dataResponseMessage) { + final QueueItem currentlyUploadingItem = this.currentlyUploadingItem; + if (currentlyUploadingItem == null) { + LOG.error("Upload request response arrived, but nothing is being uploaded"); + return; + } + + if (dataResponseMessage.status == VivomoveConstants.STATUS_ACK && dataResponseMessage.response == FileTransferDataResponseMessage.RESPONSE_TRANSFER_SUCCESSFUL) { + int nextOffset = currentlyUploadingItem.dataOffset + currentlyUploadingItem.blockSize; + if (dataResponseMessage.nextDataOffset != nextOffset) { + LOG.warn("Bad expected data offset of #{}: {} expected, {} received", currentlyUploadingItem.fileIndex, currentlyUploadingItem.dataOffset, dataResponseMessage.nextDataOffset); + communicator.sendMessage(new FileTransferDataResponseMessage(VivomoveConstants.STATUS_ACK, FileTransferDataResponseMessage.RESPONSE_ERROR_DATA_OFFSET_MISMATCH, currentlyUploadingItem.dataOffset).packet); + return; + } + + if (nextOffset >= currentlyUploadingItem.fileSize) { + LOG.info("Transfer of file #{} complete, {}/{}B uploaded", currentlyUploadingItem.fileIndex, nextOffset, currentlyUploadingItem.fileSize); + this.currentlyUploadingItem = null; + checkStartNextUpload(); + return; + } + + // prepare next block + final int blockSize = Math.min(currentlyUploadingItem.fileSize - nextOffset, MAX_BLOCK_SIZE); + currentlyUploadingItem.dataOffset = nextOffset; + currentlyUploadingItem.blockSize = blockSize; + final byte[] blockData = Arrays.copyOfRange(currentlyUploadingItem.data, nextOffset, blockSize); + final int blockCrc = ChecksumCalculator.computeCrc(currentCrc, blockData, 0, blockSize); + currentlyUploadingItem.blockCrc = blockCrc; + + LOG.info("Sending {}B@{}/{} of {}", blockSize, currentlyUploadingItem.dataOffset, currentlyUploadingItem.fileSize, currentlyUploadingItem.fileIndex); + communicator.sendMessage(new FileTransferDataMessage(UPLOAD_FLAGS, blockCrc, currentlyUploadingItem.dataOffset, blockData).packet); + } else { + // TODO: Solve individual responses + LOG.error("Received error response for data transfer of {}: {}/{}", currentlyUploadingItem.fileIndex, dataResponseMessage.status, dataResponseMessage.response); + // ??!? + cancelAllUploads(); + } + } + + private boolean isIdle() { + return currentlyUploadingItem == null; + } + + private static class QueueItem { + public final boolean create; + public final int fileSize; + public final int dataType; + public final int subType; + public final int fileIdentifier; + public final String targetPath; + public final byte[] data; + + public int fileIndex; + public int dataOffset; + public int blockSize; + public int blockCrc; + + public QueueItem(int fileSize, int dataType, int subType, int fileIdentifier, String targetPath, byte[] data) { + this.create = true; + this.fileSize = fileSize; + this.dataType = dataType; + this.subType = subType; + this.fileIdentifier = fileIdentifier; + this.targetPath = targetPath; + this.data = data; + } + + public QueueItem(int fileSize, int fileIndex, byte[] data) { + this.create = false; + this.fileSize = fileSize; + this.fileIndex = fileIndex; + this.data = data; + this.dataType = 0; + this.subType = 0; + this.fileIdentifier = 0; + this.targetPath = null; + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/ArrayUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/ArrayUtils.java index adef42285..87b590cb6 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/ArrayUtils.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/ArrayUtils.java @@ -16,9 +16,7 @@ along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.util; -import java.util.ArrayList; import java.util.Collection; -import java.util.List; public class ArrayUtils { /** @@ -94,4 +92,31 @@ public class ArrayUtils { } return stringBuilder.toString(); } + + + /** + * Finds a value in a byte array + * @param value the value to find in the array + * @param array the array to search + * @return index of the first occurrence of the value in the array, -1 if the array does not contain the value + */ + public static int indexOf(byte value, byte[] array) { + return indexOf(value, array, 0, array.length); + } + + /** + * Finds a value in a byte array + * @param value the value to find in the array + * @param array the array to search + * @param offset initial offset in the array to be searched (0 = start at the beginning) + * @param size number of bytes to search beginning at the given offset + * @return index of the first occurrence of the value in the array (from the beginning of the array, i.e. not from + * the offset position), -1 if the array does not contain the value + */ + public static int indexOf(byte value, byte[] array, int offset, int size) { + for (int i = offset; i < offset + size; ++i) { + if (array[i] == value) return i; + } + return -1; + } } diff --git a/app/src/main/proto/garmin_vivomovehr/gdi_core.proto b/app/src/main/proto/garmin_vivomovehr/gdi_core.proto new file mode 100644 index 000000000..a4c049b99 --- /dev/null +++ b/app/src/main/proto/garmin_vivomovehr/gdi_core.proto @@ -0,0 +1,79 @@ +syntax = "proto2"; + +package garmin_vivomovehr; + +option java_package = "nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.protobuf"; + +message CoreService { + optional SyncRequest sync_request = 1; + optional SyncResponse sync_response = 2; + optional GetLocationRequest get_location_request = 3; + optional GetLocationResponse get_location_response = 4; + optional LocationUpdatedSetEnabledRequest location_updated_set_enabled_request = 5; + optional LocationUpdatedSetEnabledResponse location_updated_set_enabled_response = 6; + optional LocationUpdatedNotification location_updated_notification = 7; + + message SyncRequest { + } + + message SyncResponse { + optional ResponseStatus status = 1; + + enum ResponseStatus { + UNKNOWN_RESPONSE_STATUS = 0; + OK = 1; + } + } + + message GetLocationRequest { + optional RequestType request_type = 1; + + enum RequestType { + STANDARD = 0; + EMERGENCY = 1; + } + } + + message GetLocationResponse { + optional Status status = 1; + optional LocationData location_data = 2; + + enum Status { + OK = 1; + NO_VALID_LOCATION = 2; + LOCATION_SERVICES_UNAVAILABLE = 3; + LOCATION_SERVICES_DISABLED = 4; + TRY_AGAIN_LATER = 5; + } + } + + message LocationUpdatedSetEnabledRequest { + optional bool enabled = 1; + repeated Request requests = 2; + } + + message Request { + 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 + } + + message LocationUpdatedNotification { + repeated LocationData location_data = 1; + } + + message LocationData { + // TODO + } +} diff --git a/app/src/main/proto/garmin_vivomovehr/gdi_device_status.proto b/app/src/main/proto/garmin_vivomovehr/gdi_device_status.proto new file mode 100644 index 000000000..85ce73197 --- /dev/null +++ b/app/src/main/proto/garmin_vivomovehr/gdi_device_status.proto @@ -0,0 +1,44 @@ +syntax = "proto2"; + +package garmin_vivomovehr; + +option java_package = "nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.protobuf"; + +message DeviceStatusService { + optional RemoteDeviceBatteryStatusChangedNotification remote_device_battery_status_changed_notification = 1; + optional RemoteDeviceBatteryStatusRequest remote_device_battery_status_request = 2; + optional RemoteDeviceBatteryStatusResponse remote_device_battery_status_response = 3; + optional ActivityStatusRequest activity_status_request = 4; + optional ActivityStatusResponse activity_status_response = 5; + + message RemoteDeviceBatteryStatusChangedNotification { + } + + message RemoteDeviceBatteryStatusRequest { + } + + message RemoteDeviceBatteryStatusResponse { + optional ResponseStatus status = 1; + optional int32 current_battery_level = 2; + } + + message ActivityStatusRequest { + } + + message ActivityStatusResponse { + optional ActivityStatus status = 1; + + enum ActivityStatus { + OFF = 0; + STOPPED = 1; + PAUSED = 2; + ON = 3; + } + } + + enum ResponseStatus { + UNKNOWN_RESPONSE_STATUS = 0; + OK = 1; + NO_REMOTE_DEVICE = 2; + } +} diff --git a/app/src/main/proto/garmin_vivomovehr/gdi_find_my_watch.proto b/app/src/main/proto/garmin_vivomovehr/gdi_find_my_watch.proto new file mode 100644 index 000000000..32979e153 --- /dev/null +++ b/app/src/main/proto/garmin_vivomovehr/gdi_find_my_watch.proto @@ -0,0 +1,33 @@ +syntax = "proto2"; + +package garmin_vivomovehr; + +option java_package = "nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.protobuf"; + +message FindMyWatchService { + optional FindMyWatchRequest find_request = 1; + optional FindMyWatchResponse find_response = 2; + optional FindMyWatchCancelRequest cancel_request = 3; + optional FindMyWatchCancelResponse cancel_response = 4; + + message FindMyWatchRequest { + required int32 timeout = 1; + } + + message FindMyWatchResponse { + optional ResponseStatus status = 1; + } + + message FindMyWatchCancelRequest { + } + + message FindMyWatchCancelResponse { + optional ResponseStatus status = 1; + } + + enum ResponseStatus { + UNKNOWN_RESPONSE_STATUS = 0; + OK = 100; + ERROR = 200; + } +} diff --git a/app/src/main/proto/garmin_vivomovehr/gdi_smart_proto.proto b/app/src/main/proto/garmin_vivomovehr/gdi_smart_proto.proto new file mode 100644 index 000000000..9aeb887e9 --- /dev/null +++ b/app/src/main/proto/garmin_vivomovehr/gdi_smart_proto.proto @@ -0,0 +1,53 @@ +syntax = "proto2"; + +package garmin_vivomovehr; + +option java_package = "nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.protobuf"; + +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"; + +message Smart { + optional DeviceStatusService device_status_service = 8; + optional FindMyWatchService find_my_watch_service = 12; + optional CoreService core_service = 13; + optional SmsNotificationService sms_notification_service = 16; +} + +/* +1: CALENDAR_EVENTS_SERVICE_FIELD_NUMBER +2: CONNECT_IQ_HTTP_SERVICE_FIELD_NUMBER +3: CONNECT_IQ_INSTALLED_APPS_SERVICE_FIELD_NUMBER +4: CONNECT_IQ_APP_SETTINGS_SERVICE_FIELD_NUMBER +5: INTERNATIONAL_GOLF_SERVICE_FIELD_NUMBER +6: SWING_SENSOR_SERVICE_FIELD_NUMBER +7: DATA_TRANSFER_SERVICE_FIELD_NUMBER +8: DEVICE_STATUS_SERVICE_FIELD_NUMBER +9: INCIDENT_DETECTION_SERVICE_FIELD_NUMBER +10: AUDIO_PROMPTS_SERVICE_FIELD_NUMBER +11: WIFI_SETUP_SERVICE_FIELD_NUMBER +12: FIND_MY_WATCH_SERVICE_FIELD_NUMBER +13: CORE_SERVICE_FIELD_NUMBER +14: GROUP_LIVE_TRACK_SERVICE_FIELD_NUMBER +15: EXPRESSPAY_COMMAND_SERVICE_FIELD_NUMBER +16: SMS_NOTIFICATION_SERVICE_FIELD_NUMBER +17: LIVE_TRACK_MESSAGING_SERVICE_FIELD_NUMBER +18: INSTANT_INPUT_SERVICE_FIELD_NUMBER +19: SPORT_PROFILE_SETUP_SERVICE_FIELD_NUMBER +20: HSA_DATA_SERVICE_FIELD_NUMBER +21: LIVE_TRACK_SERVICE_FIELD_NUMBER +22: EXPLORE_SYNC_SERVICE_FIELD_NUMBER +23: WAY_POINT_TRANSFER_SERVICE_FIELD_NUMBER +24: DEVICE_MESSAGE_SERVICE_FIELD_NUMBER +25: LTE_SERVICE_FIELD_NUMBER +26: ANTI_THEFT_ALARM_SERVICE_FIELD_NUMBER +27: CREDENTIALS_SERVICE_FIELD_NUMBER +28: INREACH_TRACKING_SERVICE_FIELD_NUMBER +29: INREACH_MESSAGING_SERVICE_FIELD_NUMBER +30: EVENT_SHARING_FIELD_NUMBER +31: GENERIC_ITEM_TRANSFER_SERVICE_FIELD_NUMBER +32: INREACH_CONTACT_SYNC_SERVICE_FIELD_NUMBER +33: HAND_CALIBRATION_SERVICE_FIELD_NUMBER +*/ diff --git a/app/src/main/proto/garmin_vivomovehr/gdi_sms_notification.proto b/app/src/main/proto/garmin_vivomovehr/gdi_sms_notification.proto new file mode 100644 index 000000000..a86489c66 --- /dev/null +++ b/app/src/main/proto/garmin_vivomovehr/gdi_sms_notification.proto @@ -0,0 +1,50 @@ +syntax = "proto2"; + +package garmin_vivomovehr; + +option java_package = "nodomain.freeyourgadget.gadgetbridge.service.devices.vivomovehr.protobuf"; + +message SmsNotificationService { + optional SmsSendMessageRequest sms_send_message_request = 1; + optional SmsSendMessageResponse sms_send_message_response = 2; + optional SmsCannedListChangedNotification sms_canned_list_changed_notification = 3; + optional SmsCannedListRequest sms_canned_list_request = 4; + optional SmsCannedListResponse sms_canned_list_response = 5; + + message SmsSendMessageRequest { + optional string receiver_number = 1; + optional string message = 2; + } + + message SmsSendMessageResponse { + optional ResponseStatus status = 1; + } + + message SmsCannedListChangedNotification { + repeated CannedListType changed_type = 1; + } + + message SmsCannedListRequest { + repeated CannedListType requested_types = 1; + } + + message SmsCannedListResponse { + optional ResponseStatus status = 1; + repeated SmsCannedList lists = 2; + } + + message SmsCannedList { + optional CannedListType type = 1; + repeated string response = 2; + } + + enum CannedListType { + PHONE_CALL_RESPONSE = 0; + SMS_MESSAGE_RESPONSE = 1; + } + + enum ResponseStatus { + SUCCESS = 0; + GENERIC_ERROR = 1; + } +}