Compare commits

...

35 Commits

Author SHA1 Message Date
kuhy da9f455ab8 Garmin protocol: improve detection of successfully sent files (DataTransferHandler) 2024-04-25 18:21:46 +02:00
kuhy 9bf92972ba Garmin protocol: add support for AGPS data retrieval 2024-04-24 23:23:58 +02:00
José Rebelo 438bfa4cce Garmin: Map all known files types 2024-04-23 20:06:51 +01:00
José Rebelo 13c72d827f Garmin: Add support for http weather requests 2024-04-23 20:06:50 +01:00
Daniele Gobbetti 1a120bcd50 Garmin: Rename LocalMessage to PredefinedLocalMessage and clarify its usage
PredefinedLocalMessage are only useful for FIT messages and should not interfere with FIT files. The only impact of using the local message in fit files was in the textual output, but it was confusing.

Add an explicit constructor to RecordHeader if PredefinedLocalMessage should be taken into account, and use this only in fit messages leaving the default constructor for fit files.

Also adjusts the test case as textual output comparison needs to be fixed.
2024-04-23 16:39:59 +02:00
kuhy 9b27a71328 Initial support for Garmin Vivoactive 4S 2024-04-23 16:13:39 +02:00
Daniele Gobbetti ba560a9a4f Garmin: Add support for custom replies (notifications and calls)
To enable custom replies an override must be defined in the devices coordinator that actually support custom replies.

The custom preferences allow to:
- enable / disable the default message suffix (Instinct 2 appends "sent from my $vendor device" to each reply by default)
- define custom messages to reply to calls and incoming messages (leaving those lists empty will enable the default messages to be used)

Also adds a new protobuf definition file of mostly unknown values that enable toggling the message suffix on Instinct 2.
2024-04-23 16:11:37 +02:00
myxor f0efc53d54 Initial support for Garmin Vivoactive 5 2024-04-21 11:12:57 +02:00
Daniele Gobbetti f127c47fe9 Garmin: Add support for replying to notifications
This uses the (assumed) new method of passing multiple actions, instead of the (assumed) legacy accept/decline approach.
At the moment the preset messages stored on the watch firmware are used for replying, the code supports using custom messages already but those have to be updated to the watch somehow (probably by protobuf) and this is not supported yet. Using custom messages if they are not set will just do nothing.
The NotificationActionIconPosition values have been determined on a vĂ­vomove Style and might not work properly on other watches.
The evaluation of GBDeviceEvent have been moved in GarminSupport since the notification actions handling uses device events.

Also adds a method to read null terminated strings to GarminByteBufferReader.
Also adds a warning in NotificationListener if the wrong handle is used for replying to a notification.
2024-04-20 16:37:50 +02:00
Daniele Gobbetti 4c25ae2d83 Garmin: Add FileDownloadedDeviceEvent and (disabled) file deletion
Also adds (disabled) file deletion in case of already downloaded files
2024-04-19 17:07:27 +02:00
Daniele Gobbetti b2d6f4492a Garmin: Add DST/Timezone support 2024-04-19 16:45:58 +02:00
hrdl 1a9fe65a52 Add Garmin Forerunner 245 2024-04-19 13:07:56 +02:00
Daniele Gobbetti 9cc5635474 Garmin: Support file archival (deletion) on watch
Also add original timestamp to local cache filename as the file identifier are reused
Also fix imports of Test class
2024-04-18 17:57:05 +02:00
José Rebelo be66a73fd1 Garmin: Fetch activity on demand 2024-04-18 17:57:02 +02:00
José Rebelo 930bd208ae Garmin: Fix proguard rules for release builds 2024-04-18 17:50:17 +02:00
José Rebelo 6074406ccc Garmin: Allow high MTU 2024-04-18 17:50:17 +02:00
José Rebelo c710f92dc0 Garmin protocol: Simplify FILE_TYPE 2024-04-18 17:50:17 +02:00
José Rebelo 7afb85ba03 Garmin protocol: Fix linter warnings 2024-04-18 17:50:17 +02:00
José Rebelo 4a15e1aa92 Garmin protocol: Introduce GarminCoordinator 2024-04-18 17:50:17 +02:00
José Rebelo 2dd6859648 Garmin protocol: fix crash when stopping find phone 2024-04-18 17:50:17 +02:00
Daniele Gobbetti 7829c5f1fb Garmin protocol: basic file transfer and notification handling
adds synchronization of supported files from watch to external directory
adds support for Activity and Monitoring files (workouts and activity samples), but those are not integrated yet
adds upload functionality (not used ATM and not tested)
adds notification support without actions
introduces centralized processing of "messageHandlers" (protobuf, file transfer, notifications)

also properly dispose of the music timer when disconnecting
2024-04-18 17:50:17 +02:00
Daniele Gobbetti 5a273c1118 Garmin protocol: enable media volume control from watch 2024-04-18 17:50:17 +02:00
Daniele Gobbetti 2473ae2f52 Garmin protocol: store max packet size from DeviceInformationMessage
also adds messageType to the warnifleftover log message
2024-04-18 17:50:17 +02:00
Daniele Gobbetti f94299fcc1 Garmin protocol: various changes
- add FitFile class that deals with parsing and generating outgoing files
- consider all field definitions with number 253 as Timestamps [0]
- add support for "compressed timestamps" in fit file parsing. Those are not returned among the other normal fields but are available through a method of RecordData
- adjust the test cases

[0]48b6554d8a/fitdecode/reader.py (L719)
2024-04-18 17:50:16 +02:00
Daniele Gobbetti f05b7f44a9 Garmin protocol: change naming and logic of several FIT classes
- refactor the logic of Global and Local messages
- add some Global messages with naming taken from [1]
- Global messages are not enum because there are too many
- introduce the concept of FieldDefinitionPrimitive
- add new Field Definitions
- add support for developer fields and array fields
- add test case for FIT files taken from [0]

[0] https://github.com/polyvertex/fitdecode/
[1] https://www.fitfileviewer.com/
2024-04-18 17:50:16 +02:00
Daniele Gobbetti fc5b8c5641 Garmin protocol: create helper class GarminByteBufferReader
separate the logic specific for GFDI messages from the generally useful logic.
Also centralize the logging in case of leftover bytes while parsing GFDI messages.
2024-04-18 17:50:16 +02:00
Daniele Gobbetti 944b1025c2 Garmin protocol: create custom GBDeviceEvent for weather request 2024-04-18 17:50:16 +02:00
Daniele Gobbetti e81404379e Garmin protocol: use message enum instead of id in GFDI Messages 2024-04-18 17:50:16 +02:00
Daniele Gobbetti cebcd24c68 Garmin protocol: refactoring and fixes of BaseTypes
The boundaries are enforced on the stored value when decoding, before applying the adjustments for scale and offset.
Also add some tests for the BaseTypes
Introduce new FieldDefinition for Temperature and WeatherCondition (removing the static class)
Add accessors for field data in the containing RecordData, thus keeping the FieldData private
2024-04-18 17:50:16 +02:00
Daniele Gobbetti 1d8cd7dd1e Garmin protocol: create specific field definition for day of week 2024-04-18 17:50:16 +02:00
Daniele Gobbetti 1f7f502fe9 Garmin protocol: move field encode/decode interface to the FieldDefinition
This allows for semantic subclassing the FieldDefinition.
A FieldDefinitionTimestamp subclass is introduced as example
2024-04-18 17:50:16 +02:00
Daniele Gobbetti d00b94f333 Garmin protocol: fix invalid signed int base type value 2024-04-18 17:50:16 +02:00
Daniele Gobbetti 16354d333e Garmin protocol: add initial support for FIT messages
note: only weather message definition and data tested so far
also enable weather support for Instinct 2S and vivomove style
also cleanup some unused constants that have been migrated to new enums in GFDIMessage
additionally switch to new local implementation of GarminTimeUtils with needed methods
2024-04-18 17:50:16 +02:00
Daniele Gobbetti 8107002a87 Garmin protocol: fixes
- fix DEVICE_SETTINGS message ID
- put all status messages in own package
- allow protobuf handler to change the returned status message to signal unsupported requests
- fix various bugs
2024-04-18 17:50:16 +02:00
Daniele Gobbetti f1111d3790 Garmin protocol: initial refactoring and basic functionalities
This commit takes aims to bring many new garmin devices up to a working status, with basic functionalities such as:
- garmin protocol initialization
- basic message exchange
- support for some messages in Garmin own format
- support for some messages in protobuf format
2024-04-18 17:50:15 +02:00
119 changed files with 9142 additions and 62 deletions

View File

@ -34,6 +34,12 @@
}
-keepattributes JavascriptInterface
# Keep parseIncoming for GFDIMessage classes, as it is called by reflection in GFDIMessage#parseIncoming
-keep public class * extends nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.GFDIMessage
-keepclassmembers class * extends nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.GFDIMessage {
public static *** parseIncoming(...);
}
# https://github.com/tony19/logback-android/issues/29
-dontwarn javax.mail.**

View File

@ -441,4 +441,5 @@ public class DeviceSettingsPreferenceConst {
public static final String PREF_AUTO_REPLY_INCOMING_CALL = "pref_auto_reply_phonecall";
public static final String PREF_AUTO_REPLY_INCOMING_CALL_DELAY = "pref_auto_reply_phonecall_delay";
public static final String PREF_SPEAK_NOTIFICATIONS_ALOUD = "pref_speak_notifications_aloud";
public static final String PREF_GARMIN_DEFAULT_REPLY_SUFFIX = "pref_key_garmin_default_reply_suffix";
}

View File

@ -19,23 +19,6 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.devicesettings;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.*;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_CONTROL_CENTER_SORTABLE;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_DISPLAY_ITEMS;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_DISPLAY_ITEMS_SORTABLE;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_EXPOSE_HR_THIRDPARTY;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_SHORTCUTS;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_SHORTCUTS_SORTABLE;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_WORKOUT_ACTIVITY_TYPES_SORTABLE;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_MI2_DATEFORMAT;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_MI2_ROTATE_WRIST_TO_SWITCH_INFO;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_NIGHT_MODE;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_NIGHT_MODE_END;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_NIGHT_MODE_OFF;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_NIGHT_MODE_SCHEDULED;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_NIGHT_MODE_START;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_SWIPE_UNLOCK;
import android.content.Context;
import android.content.Intent;
import android.media.AudioManager;
@ -78,6 +61,23 @@ import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.CannedMessagesSpec;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.*;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_CONTROL_CENTER_SORTABLE;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_DISPLAY_ITEMS;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_DISPLAY_ITEMS_SORTABLE;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_EXPOSE_HR_THIRDPARTY;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_SHORTCUTS;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_SHORTCUTS_SORTABLE;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_WORKOUT_ACTIVITY_TYPES_SORTABLE;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_MI2_DATEFORMAT;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_MI2_ROTATE_WRIST_TO_SWITCH_INFO;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_NIGHT_MODE;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_NIGHT_MODE_END;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_NIGHT_MODE_OFF;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_NIGHT_MODE_SCHEDULED;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_NIGHT_MODE_START;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_SWIPE_UNLOCK;
public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment implements DeviceSpecificSettingsHandler {
private static final Logger LOG = LoggerFactory.getLogger(DeviceSpecificSettingsFragment.class);
@ -622,6 +622,8 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i
addPreferenceHandlerFor(PREF_HEARTRATE_AUTOMATIC_ENABLE);
addPreferenceHandlerFor(PREF_SPO_AUTOMATIC_ENABLE);
addPreferenceHandlerFor(PREF_GARMIN_DEFAULT_REPLY_SUFFIX);
addPreferenceHandlerFor("lock");
String sleepTimeState = prefs.getString(PREF_SLEEP_TIME, PREF_DO_NOT_DISTURB_OFF);

View File

@ -0,0 +1,70 @@
package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
import androidx.annotation.NonNull;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.GBException;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.Device;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupport;
public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator {
@Override
protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
}
@Override
public String getManufacturer() {
return "Garmin";
}
@NonNull
@Override
public Class<? extends DeviceSupport> getDeviceSupportClass() {
return GarminSupport.class;
}
@Override
public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
connection.add(R.xml.devicesettings_high_mtu);
if (getCannedRepliesSlotCount(device) > 0) {
final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALLS_AND_NOTIFICATIONS);
notifications.add(R.xml.devicesettings_garmin_default_reply_suffix);
notifications.add(R.xml.devicesettings_canned_reply_16);
notifications.add(R.xml.devicesettings_canned_dismisscall_16);
}
final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
developer.add(R.xml.devicesettings_keep_activity_data_on_device);
return deviceSpecificSettings;
}
@Override
public boolean supportsActivityDataFetching() {
return true;
}
@Override
public boolean supportsFindDevice() {
return true;
}
@Override
public boolean supportsWeather() {
return true;
}
}

View File

@ -0,0 +1,5 @@
package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
public class GarminPreferences {
public static final String PREF_GARMIN_CAPABILITIES = "garmin_capabilities";
}

View File

@ -0,0 +1,18 @@
package nodomain.freeyourgadget.gadgetbridge.devices.garmin.forerunner245;
import java.util.regex.Pattern;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
public class GarminForerunner245Coordinator extends GarminCoordinator {
@Override
protected Pattern getSupportedDeviceName() {
return Pattern.compile("Forerunner 245");
}
@Override
public int getDeviceNameResource() {
return R.string.devicetype_garmin_forerunner_245;
}
}

View File

@ -0,0 +1,24 @@
package nodomain.freeyourgadget.gadgetbridge.devices.garmin.instinct2s;
import java.util.regex.Pattern;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
public class GarminInstinct2SCoordinator extends GarminCoordinator {
@Override
protected Pattern getSupportedDeviceName() {
return Pattern.compile("Instinct 2S");
}
@Override
public int getCannedRepliesSlotCount(final GBDevice device) {
return 16;
}
@Override
public int getDeviceNameResource() {
return R.string.devicetype_garmin_instinct_2s;
}
}

View File

@ -0,0 +1,18 @@
package nodomain.freeyourgadget.gadgetbridge.devices.garmin.venu3;
import java.util.regex.Pattern;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
public class GarminVenu3Coordinator extends GarminCoordinator {
@Override
protected Pattern getSupportedDeviceName() {
return Pattern.compile("Venu 3");
}
@Override
public int getDeviceNameResource() {
return R.string.devicetype_garmin_vivomove_style;
}
}

View File

@ -0,0 +1,18 @@
package nodomain.freeyourgadget.gadgetbridge.devices.garmin.vivoactive4s;
import java.util.regex.Pattern;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
public class GarminVivoActive4SCoordinator extends GarminCoordinator {
@Override
protected Pattern getSupportedDeviceName() {
return Pattern.compile("vĂ­voactive 4S");
}
@Override
public int getDeviceNameResource() {
return R.string.devicetype_garmin_vivoactive_4s;
}
}

View File

@ -0,0 +1,18 @@
package nodomain.freeyourgadget.gadgetbridge.devices.garmin.vivoactive5;
import java.util.regex.Pattern;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
public class GarminVivoActive5Coordinator extends GarminCoordinator {
@Override
protected Pattern getSupportedDeviceName() {
return Pattern.compile("vĂ­voactive 5");
}
@Override
public int getDeviceNameResource() {
return R.string.devicetype_garmin_vivoactive_5;
}
}

View File

@ -0,0 +1,18 @@
package nodomain.freeyourgadget.gadgetbridge.devices.garmin.vivomove;
import java.util.regex.Pattern;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
public class GarminVivomoveStyleCoordinator extends GarminCoordinator {
@Override
protected Pattern getSupportedDeviceName() {
return Pattern.compile("vĂ­vomove Style");
}
@Override
public int getDeviceNameResource() {
return R.string.devicetype_garmin_vivomove_style;
}
}

View File

@ -143,7 +143,7 @@ public class VivomoveHrCoordinator extends AbstractBLEDeviceCoordinator {
@Override
public int getDeviceNameResource() {
return R.string.devicetype_vivomove_hr;
return R.string.devicetype_garmin_vivomove_hr;
}
@Override

View File

@ -221,6 +221,8 @@ public class NotificationListener extends NotificationListenerService {
} catch (PendingIntent.CanceledException e) {
LOG.warn("replyToLastNotification error: " + e.getLocalizedMessage());
}
} else {
LOG.warn("Received ACTION_REPLY but cannot find the corresponding wearableAction");
}
break;
}

View File

@ -49,6 +49,12 @@ import nodomain.freeyourgadget.gadgetbridge.devices.galaxy_buds.GalaxyBuds2ProDe
import nodomain.freeyourgadget.gadgetbridge.devices.galaxy_buds.GalaxyBudsDeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.galaxy_buds.GalaxyBudsLiveDeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.galaxy_buds.GalaxyBudsProDeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.forerunner245.GarminForerunner245Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.instinct2s.GarminInstinct2SCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.venu3.GarminVenu3Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.vivoactive4s.GarminVivoActive4SCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.vivoactive5.GarminVivoActive5Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.vivomove.GarminVivomoveStyleCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.hplus.EXRIZUK8Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.hplus.HPlusCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.hplus.MakibesF68Coordinator;
@ -136,9 +142,9 @@ import nodomain.freeyourgadget.gadgetbridge.devices.lenovo.watchxplus.WatchXPlus
import nodomain.freeyourgadget.gadgetbridge.devices.liveview.LiveviewCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.makibeshr3.MakibesHR3Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.mijia_lywsd.MijiaMhoC303Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.mijia_lywsd.MijiaLywsd02Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.mijia_lywsd.MijiaLywsd03Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.mijia_lywsd.MijiaMhoC303Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.miscale2.MiScale2DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.no1f1.No1F1Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.nothing.Ear1Coordinator;
@ -160,11 +166,11 @@ import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators
import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWF1000XM4Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWF1000XM5Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWFSP800NCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWISP600NCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWH1000XM2Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWH1000XM3Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWH1000XM4Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWH1000XM5Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWISP600NCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.sony.wena3.SonyWena3Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.sonyswr12.SonySWR12DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.supercars.SuperCarsCoordinator;
@ -181,6 +187,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miband7pro.MiBand7Pro
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miband8.MiBand8Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miband8active.MiBand8ActiveCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miband8pro.MiBand8ProCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miwatch.MiWatchLiteCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miwatchcolorsport.MiWatchColorSportCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmismartband2.RedmiSmartBand2Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmismartbandpro.RedmiSmartBandProCoordinator;
@ -194,7 +201,6 @@ import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.watchs1pro.XiaomiWatc
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.watchs3.XiaomiWatchS3Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.xwatch.XWatchCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.zetime.ZeTimeCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miwatch.MiWatchLiteCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmiwatch3active.RedmiWatch3ActiveCoordinator;
/**
@ -321,6 +327,12 @@ public enum DeviceType {
ITAG(ITagCoordinator.class),
NUTMINI(NutCoordinator.class),
VIVOMOVE_HR(VivomoveHrCoordinator.class),
GARMIN_FORERUNNER_245(GarminForerunner245Coordinator.class),
GARMIN_INSTINCT_2S(GarminInstinct2SCoordinator.class),
GARMIN_VIVOMOVE_STYLE(GarminVivomoveStyleCoordinator.class),
GARMIN_VENU_3(GarminVenu3Coordinator.class),
GARMIN_VIVOACTIVE_4S(GarminVivoActive4SCoordinator.class),
GARMIN_VIVOACTIVE_5(GarminVivoActive5Coordinator.class),
VIBRATISSIMO(VibratissimoCoordinator.class),
SONY_SWR12(SonySWR12DeviceCoordinator.class),
LIVEVIEW(LiveviewCoordinator.class),

View File

@ -744,7 +744,7 @@ public abstract class AbstractDeviceSupport implements DeviceSupport {
LocalBroadcastManager.getInstance(context).sendBroadcast(messageIntent);
}
protected Prefs getDevicePrefs() {
public Prefs getDevicePrefs() {
return new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()));
}

View File

@ -65,7 +65,6 @@ import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateA
import nodomain.freeyourgadget.gadgetbridge.service.serial.GBDeviceProtocol;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.MediaManager;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
public class CmfWatchProSupport extends AbstractBTLEDeviceSupport implements CmfCharacteristic.Handler {
private static final Logger LOG = LoggerFactory.getLogger(CmfWatchProSupport.class);
@ -177,11 +176,6 @@ public class CmfWatchProSupport extends AbstractBTLEDeviceSupport implements Cmf
mediaManager = new MediaManager(context);
}
@Override
protected Prefs getDevicePrefs() {
return super.getDevicePrefs();
}
@Override
public boolean onCharacteristicChanged(final BluetoothGatt gatt,
final BluetoothGattCharacteristic characteristic) {

View File

@ -0,0 +1,50 @@
/* Copyright (C) 2023-2024 Petr Kadlec
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin;
import java.nio.ByteBuffer;
public final class ChecksumCalculator {
private static final int[] CONSTANTS = {
0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401,
0xA001, 0x6C00,0x7800, 0xB401, 0x5000, 0x9C01, 0x8801, 0x4400
};
private ChecksumCalculator() {
}
public static int computeCrc(byte[] data, int offset, int length) {
return computeCrc(0, data, offset, length);
}
public static int computeCrc(ByteBuffer byteBuffer, int offset, int length) {
byteBuffer.rewind();
byte[] data = new byte[length];
byteBuffer.get(data);
return computeCrc(0, data, offset, length);
}
public static int computeCrc(int initialCrc, byte[] data, int offset, int length) {
int crc = initialCrc;
for (int i = offset; i < offset + length; ++i) {
int b = data[i];
crc = (((crc >> 4) & 4095) ^ CONSTANTS[crc & 15]) ^ CONSTANTS[b & 15];
crc = (((crc >> 4) & 4095) ^ CONSTANTS[crc & 15]) ^ CONSTANTS[(b >> 4) & 15];
}
return crc;
}
}

View File

@ -0,0 +1,358 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin;
import androidx.annotation.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashSet;
import java.util.Set;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents.FileDownloadedDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.DownloadRequestMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.FileTransferDataMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.GFDIMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.SystemEventMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.UploadRequestMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.CreateFileStatusMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.DownloadRequestStatusMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.FileTransferDataStatusMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.UploadRequestStatusMessage;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
public class FileTransferHandler implements MessageHandler {
private static final Logger LOG = LoggerFactory.getLogger(FileTransferHandler.class);
private final GarminSupport deviceSupport;
private final Download download;
private final Upload upload;
private static final Set<FileType.FILETYPE> FILE_TYPES_TO_PROCESS = new HashSet<FileType.FILETYPE>() {{
add(FileType.FILETYPE.DIRECTORY);
add(FileType.FILETYPE.ACTIVITY);
add(FileType.FILETYPE.MONITOR);
add(FileType.FILETYPE.METRICS);
add(FileType.FILETYPE.CHANGELOG);
add(FileType.FILETYPE.SLEEP);
}};
public FileTransferHandler(GarminSupport deviceSupport) {
this.deviceSupport = deviceSupport;
this.download = new Download();
this.upload = new Upload();
}
public boolean isDownloading() {
return download.getCurrentlyDownloading() != null;
}
public boolean isUploading() {
return upload.getCurrentlyUploading() != null;
}
public GFDIMessage handle(GFDIMessage message) {
if (message instanceof DownloadRequestStatusMessage)
download.processDownloadRequestStatusMessage((DownloadRequestStatusMessage) message);
else if (message instanceof FileTransferDataMessage)
download.processDownloadChunkedMessage((FileTransferDataMessage) message);
else if (message instanceof CreateFileStatusMessage)
return upload.setCreateFileStatusMessage((CreateFileStatusMessage) message);
else if (message instanceof UploadRequestStatusMessage)
return upload.setUploadRequestStatusMessage((UploadRequestStatusMessage) message);
else if (message instanceof FileTransferDataStatusMessage)
return upload.processUploadProgress((FileTransferDataStatusMessage) message);
return null;
}
public DownloadRequestMessage downloadDirectoryEntry(DirectoryEntry directoryEntry) {
download.setCurrentlyDownloading(new FileFragment(directoryEntry));
return new DownloadRequestMessage(directoryEntry.getFileIndex(), 0, DownloadRequestMessage.REQUEST_TYPE.NEW, 0, 0);
}
public DownloadRequestMessage initiateDownload() {
download.setCurrentlyDownloading(new FileFragment(new DirectoryEntry(0, FileType.FILETYPE.DIRECTORY, 0, 0, 0, 0, null)));
return new DownloadRequestMessage(0, 0, DownloadRequestMessage.REQUEST_TYPE.NEW, 0, 0);
}
// public DownloadRequestMessage downloadSettings() {
// download.setCurrentlyDownloading(new FileFragment(new DirectoryEntry(0, FileType.FILETYPE.SETTINGS, 0, 0, 0, 0, null)));
// return new DownloadRequestMessage(0, 0, DownloadRequestMessage.REQUEST_TYPE.NEW, 0, 0);
// }
//
// public CreateFileMessage initiateUpload(byte[] fileAsByteArray, FileType.FILETYPE filetype) {
// upload.setCurrentlyUploading(new FileFragment(new DirectoryEntry(0, filetype, 0, 0, 0, fileAsByteArray.length, null), fileAsByteArray));
// return new CreateFileMessage(fileAsByteArray.length, filetype);
// }
public class Download {
private FileFragment currentlyDownloading;
public FileFragment getCurrentlyDownloading() {
return currentlyDownloading;
}
public void setCurrentlyDownloading(FileFragment currentlyDownloading) {
this.currentlyDownloading = currentlyDownloading;
}
private void processDownloadChunkedMessage(FileTransferDataMessage fileTransferDataMessage) {
if (!isDownloading())
throw new IllegalStateException("Received file transfer of unknown file");
currentlyDownloading.append(fileTransferDataMessage);
if (!currentlyDownloading.dataHolder.hasRemaining())
processCompleteDownload();
}
private void processCompleteDownload() {
currentlyDownloading.dataHolder.flip();
if (FileType.FILETYPE.DIRECTORY.equals(currentlyDownloading.directoryEntry.filetype)) { //is a directory
parseDirectoryEntries();
} else {
saveFileToExternalStorage();
}
currentlyDownloading = null;
}
public void processDownloadRequestStatusMessage(DownloadRequestStatusMessage downloadRequestStatusMessage) {
if (null == currentlyDownloading)
throw new IllegalStateException("Received file transfer of unknown file");
if (downloadRequestStatusMessage.canProceed())
currentlyDownloading.setSize(downloadRequestStatusMessage);
else
currentlyDownloading = null;
}
private void saveFileToExternalStorage() {
File dir;
try {
dir = deviceSupport.getWritableExportDirectory();
File outputFile = new File(dir, currentlyDownloading.getFileName());
FileUtils.copyStreamToFile(new ByteArrayInputStream(currentlyDownloading.dataHolder.array()), outputFile);
outputFile.setLastModified(currentlyDownloading.directoryEntry.fileDate.getTime());
} catch (IOException e) {
LOG.error("Failed to save file", e);
}
FileDownloadedDeviceEvent fileDownloadedDeviceEvent = new FileDownloadedDeviceEvent();
fileDownloadedDeviceEvent.directoryEntry = currentlyDownloading.directoryEntry;
deviceSupport.evaluateGBDeviceEvent(fileDownloadedDeviceEvent);
}
private void parseDirectoryEntries() {
if ((currentlyDownloading.getDataSize() % 16) != 0)
throw new IllegalArgumentException("Invalid directory data length");
final GarminByteBufferReader reader = new GarminByteBufferReader(currentlyDownloading.dataHolder.array());
reader.setByteOrder(ByteOrder.LITTLE_ENDIAN);
while (reader.remaining() > 0) {
final int fileIndex = reader.readShort();//2
final int fileDataType = reader.readByte();//3
final int fileSubType = reader.readByte();//4
final FileType.FILETYPE filetype = FileType.FILETYPE.fromDataTypeSubType(fileDataType, fileSubType);
final int fileNumber = reader.readShort();//6
final int specificFlags = reader.readByte();//7
final int fileFlags = reader.readByte();//8
final int fileSize = reader.readInt();//12
final Date fileDate = new Date(GarminTimeUtils.garminTimestampToJavaMillis(reader.readInt()));//16
final DirectoryEntry directoryEntry = new DirectoryEntry(fileIndex, filetype, fileNumber, specificFlags, fileFlags, fileSize, fileDate);
if (directoryEntry.filetype == null) //silently discard unsupported files
continue;
if (!FILE_TYPES_TO_PROCESS.contains(directoryEntry.filetype))
continue;
deviceSupport.addFileToDownloadList(directoryEntry);
}
currentlyDownloading = null;
}
}
public static class Upload {
private FileFragment currentlyUploading;
private UploadRequestMessage setCreateFileStatusMessage(CreateFileStatusMessage createFileStatusMessage) {
if (createFileStatusMessage.canProceed()) {
LOG.info("SENDING UPLOAD FILE");
return new UploadRequestMessage(createFileStatusMessage.getFileIndex(), currentlyUploading.getDataSize());
} else {
LOG.warn("Cannot proceed with upload");
this.currentlyUploading = null;
}
return null;
}
private FileTransferDataMessage setUploadRequestStatusMessage(UploadRequestStatusMessage uploadRequestStatusMessage) {
if (null == currentlyUploading)
throw new IllegalStateException("Received upload request status transfer of unknown file");
if (uploadRequestStatusMessage.canProceed()) {
if (uploadRequestStatusMessage.getDataOffset() != currentlyUploading.dataHolder.position())
throw new IllegalStateException("Received upload request with unaligned offset");
return currentlyUploading.take();
} else {
LOG.warn("Cannot proceed with upload");
this.currentlyUploading = null;
}
return null;
}
private GFDIMessage processUploadProgress(FileTransferDataStatusMessage fileTransferDataStatusMessage) {
if (currentlyUploading.getDataSize() <= fileTransferDataStatusMessage.getDataOffset()) {
this.currentlyUploading = null;
LOG.info("SENDING SYNC COMPLETE!!!");
return new SystemEventMessage(SystemEventMessage.GarminSystemEventType.SYNC_COMPLETE, 0);
} else {
if (fileTransferDataStatusMessage.canProceed()) {
LOG.info("SENDING NEXT CHUNK!!!");
if (fileTransferDataStatusMessage.getDataOffset() != currentlyUploading.dataHolder.position())
throw new IllegalStateException("Received file transfer status with unaligned offset");
return currentlyUploading.take();
} else {
LOG.warn("Cannot proceed with upload");
this.currentlyUploading = null;
}
}
return null;
}
public FileFragment getCurrentlyUploading() {
return this.currentlyUploading;
}
public void setCurrentlyUploading(FileFragment currentlyUploading) {
this.currentlyUploading = currentlyUploading;
}
}
public static class FileFragment {
private final DirectoryEntry directoryEntry;
private final int maxBlockSize = 500;
private int dataSize;
private ByteBuffer dataHolder;
private int runningCrc;
FileFragment(DirectoryEntry directoryEntry) {
this.directoryEntry = directoryEntry;
this.setRunningCrc(0);
}
FileFragment(DirectoryEntry directoryEntry, byte[] contents) {
this.directoryEntry = directoryEntry;
this.setDataSize(contents.length);
this.dataHolder = ByteBuffer.wrap(contents);
this.dataHolder.flip(); //we'll be only reading from here on
this.dataHolder.compact();
this.setRunningCrc(0);
}
private int getMaxBlockSize() {
return Math.max(maxBlockSize, GFDIMessage.getMaxPacketSize());
}
public String getFileName() {
return directoryEntry.getFileName();
}
private void setSize(DownloadRequestStatusMessage downloadRequestStatusMessage) {
if (0 != getDataSize())
throw new IllegalStateException("Data size already set");
this.setDataSize(downloadRequestStatusMessage.getMaxFileSize());
this.dataHolder = ByteBuffer.allocate(getDataSize());
}
private void append(FileTransferDataMessage fileTransferDataMessage) {
if (fileTransferDataMessage.getDataOffset() != dataHolder.position())
throw new IllegalStateException("Received message that was already received");
final int dataCrc = ChecksumCalculator.computeCrc(getRunningCrc(), fileTransferDataMessage.getMessage(), 0, fileTransferDataMessage.getMessage().length);
if (fileTransferDataMessage.getCrc() != dataCrc)
throw new IllegalStateException("Received message with invalid CRC");
setRunningCrc(dataCrc);
this.dataHolder.put(fileTransferDataMessage.getMessage());
}
private FileTransferDataMessage take() {
final int currentOffset = this.dataHolder.position();
final byte[] chunk = new byte[Math.min(this.dataHolder.remaining(), getMaxBlockSize())];
this.dataHolder.get(chunk);
setRunningCrc(ChecksumCalculator.computeCrc(getRunningCrc(), chunk, 0, chunk.length));
return new FileTransferDataMessage(chunk, currentOffset, getRunningCrc());
}
private int getDataSize() {
return dataSize;
}
private void setDataSize(int dataSize) {
this.dataSize = dataSize;
}
private int getRunningCrc() {
return runningCrc;
}
private void setRunningCrc(int runningCrc) {
this.runningCrc = runningCrc;
}
}
public static class DirectoryEntry {
private final int fileIndex;
private final FileType.FILETYPE filetype;
private final int fileNumber;
private final int specificFlags;
private final int fileFlags;
private final int fileSize;
private final Date fileDate;
public DirectoryEntry(int fileIndex, FileType.FILETYPE filetype, int fileNumber, int specificFlags, int fileFlags, int fileSize, Date fileDate) {
this.fileIndex = fileIndex;
this.filetype = filetype;
this.fileNumber = fileNumber;
this.specificFlags = specificFlags;
this.fileFlags = fileFlags;
this.fileSize = fileSize;
this.fileDate = fileDate;
}
public int getFileIndex() {
return fileIndex;
}
public FileType.FILETYPE getFiletype() {
return filetype;
}
public String getFileName() {
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss");
String dateString = dateFormat.format(fileDate);
return getFiletype().name() + "_" + getFileIndex() + "_" + dateString + (getFiletype().isFitFile() ? ".fit" : ".bin");
}
@NonNull
@Override
public String toString() {
return "DirectoryEntry{" +
"fileIndex=" + fileIndex +
", fileType=" + filetype.name() +
", fileNumber=" + fileNumber +
", specificFlags=" + specificFlags +
", fileFlags=" + fileFlags +
", fileSize=" + fileSize +
", fileDate=" + fileDate +
'}';
}
}
}

View File

@ -0,0 +1,102 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin;
import androidx.annotation.Nullable;
public class FileType {
//common
//128/4: FIT_TYPE_4, -> garmin/activity
//128/32: FIT_TYPE_32, -> garmin/monitor
//128/44: FIT_TYPE_44, ->garmin/metrics
//128/41: FIT_TYPE_41, ->garmin/chnglog
//128/49: FIT_TYPE_49, -> garmin/sleep
//255/245: ErrorShutdownReports,
//Specific Instinct 2S:
//128/38: FIT_TYPE_38, -> garmin/SCORCRDS
//255/248: KPI,
//128/58: FIT_TYPE_58, -> outputFromUnit garmin/device????
//255/247: ULFLogs,
//128/68: FIT_TYPE_68, -> garmin/HRVSTATUS
//128/70: FIT_TYPE_70, -> garmin/HSA
//128/72: FIT_TYPE_72, -> garmin/FBTBACKUP
//128/74: FIT_TYPE_74
private final FILETYPE fileType;
private final String garminDeviceFileType;
public FileType(int fileDataType, int fileSubType, String garminDeviceFileType) {
this.fileType = FILETYPE.fromDataTypeSubType(fileDataType, fileSubType);
this.garminDeviceFileType = garminDeviceFileType;
}
public FILETYPE getFileType() {
return fileType;
}
public enum FILETYPE { //TODO: add specialized method to parse each file type to the enum?
// virtual/undocumented
DIRECTORY(0, 0),
// fit files
ACTIVITY(128, 4),
WORKOUTS(128, 5),
SCHEDULES(128, 7),
LOCATION(128, 8),
TOTALS(128, 10),
GOALS(128, 11),
SUMMARY(128, 20),
RECORDS(128, 29),
MONITOR(128, 32),
CLUBS(128, 37),
SCORE(128, 38),
ADJUSTMENTS(128, 39),
CHANGELOG(128, 41),
METRICS(128, 44),
SLEEP(128, 49),
MUSCLE_MAP(128, 59),
ECG(128, 61),
BENCHMARK(128, 62),
HRV_STATUS(128, 68),
HSA(128, 70),
FBT_BACKUP(128, 72),
SKIN_TEMP(128, 73),
FBT_PTD_BACKUP(128, 74),
// Other files
ERROR_SHUTDOWN_REPORTS(255, 245),
IQ_ERROR_REPORTS(255, 244),
ULF_LOGS(255, 247),
;
private final int type;
private final int subtype;
FILETYPE(final int type, final int subtype) {
this.type = type;
this.subtype = subtype;
}
@Nullable
public static FILETYPE fromDataTypeSubType(int dataType, int subType) {
for (FILETYPE ft :
FILETYPE.values()) {
if (ft.type == dataType && ft.subtype == subType)
return ft;
}
return null;
}
public int getType() {
return type;
}
public int getSubType() {
return subtype;
}
public boolean isFitFile() {
return type == 128;
}
}
}

View File

@ -0,0 +1,82 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
public class GarminByteBufferReader {
protected final ByteBuffer byteBuffer;
public GarminByteBufferReader(byte[] data) {
this.byteBuffer = ByteBuffer.wrap(data);
}
public int remaining() {
return byteBuffer.remaining();
}
public ByteBuffer asReadOnlyBuffer() {
return byteBuffer.asReadOnlyBuffer();
}
public void setByteOrder(ByteOrder byteOrder) {
this.byteBuffer.order(byteOrder);
}
public int readByte() {
return Byte.toUnsignedInt(byteBuffer.get());
}
public int getPosition() {
return byteBuffer.position();
}
public int readShort() {
return Short.toUnsignedInt(byteBuffer.getShort());
}
public int readInt() {
return byteBuffer.getInt();
}
public long readLong() {
return byteBuffer.getLong();
}
public float readFloat32() {
return byteBuffer.getFloat();
}
public double readFloat64() {
return byteBuffer.getDouble();
}
public String readString() {
final int size = readByte();
byte[] bytes = new byte[size];
byteBuffer.get(bytes);
return new String(bytes, StandardCharsets.UTF_8);
}
public String readNullTerminatedString() {
int position = byteBuffer.position();
int size = 0;
while (byteBuffer.hasRemaining()) {
if (byteBuffer.get() == 0)
break;
size++;
}
byteBuffer.position(position);
byte[] bytes = new byte[size];
byteBuffer.get(bytes);
return new String(bytes, StandardCharsets.UTF_8);
}
public byte[] readBytes(int size) {
byte[] bytes = new byte[size];
byteBuffer.get(bytes);
return bytes;
}
}

View File

@ -0,0 +1,574 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCharacteristic;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Queue;
import java.util.Timer;
import java.util.TimerTask;
import java.util.UUID;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminPreferences;
import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.GarminCapability;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.CallSpec;
import nodomain.freeyourgadget.gadgetbridge.model.CannedMessagesSpec;
import nodomain.freeyourgadget.gadgetbridge.model.MusicSpec;
import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec;
import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
import nodomain.freeyourgadget.gadgetbridge.model.Weather;
import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiDeviceStatus;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiFindMyWatch;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiSettingsService;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiSmartProto;
import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.ICommunicator;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.v1.CommunicatorV1;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.v2.CommunicatorV2;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents.FileDownloadedDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents.NotificationSubscriptionDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents.SupportedFileTypesDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents.WeatherRequestDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.PredefinedLocalMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordData;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordDefinition;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.ConfigurationMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.DownloadRequestMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.GFDIMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.MusicControlEntityUpdateMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.ProtobufMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.SetDeviceSettingsMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.SetFileFlagsMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.SupportedFileTypesMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.SystemEventMessage;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_ALLOW_HIGH_MTU;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_GARMIN_DEFAULT_REPLY_SUFFIX;
public class GarminSupport extends AbstractBTLEDeviceSupport implements ICommunicator.Callback {
private static final Logger LOG = LoggerFactory.getLogger(GarminSupport.class);
private final ProtocolBufferHandler protocolBufferHandler;
private final NotificationsHandler notificationsHandler;
private final FileTransferHandler fileTransferHandler;
private final Queue<FileTransferHandler.DirectoryEntry> filesToDownload;
private final List<MessageHandler> messageHandlers;
private ICommunicator communicator;
private MusicStateSpec musicStateSpec;
private Timer musicStateTimer;
private final List<FileType> supportedFileTypeList = new ArrayList<>();
public GarminSupport() {
super(LOG);
addSupportedService(CommunicatorV1.UUID_SERVICE_GARMIN_GFDI);
addSupportedService(CommunicatorV2.UUID_SERVICE_GARMIN_ML_GFDI);
protocolBufferHandler = new ProtocolBufferHandler(this);
fileTransferHandler = new FileTransferHandler(this);
filesToDownload = new LinkedList<>();
messageHandlers = new ArrayList<>();
notificationsHandler = new NotificationsHandler();
messageHandlers.add(fileTransferHandler);
messageHandlers.add(protocolBufferHandler);
messageHandlers.add(notificationsHandler);
}
@Override
public void dispose() {
super.dispose();
stopMusicTimer();
}
private void stopMusicTimer() {
if (musicStateTimer != null) {
musicStateTimer.cancel();
musicStateTimer.purge();
musicStateTimer = null;
}
}
public void addFileToDownloadList(FileTransferHandler.DirectoryEntry directoryEntry) {
filesToDownload.add(directoryEntry);
}
@Override
public boolean useAutoConnect() {
return false;
}
@Override
protected TransactionBuilder initializeDevice(final TransactionBuilder builder) {
builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext()));
if (getSupportedServices().contains(CommunicatorV2.UUID_SERVICE_GARMIN_ML_GFDI)) {
communicator = new CommunicatorV2(this);
} else if (getSupportedServices().contains(CommunicatorV1.UUID_SERVICE_GARMIN_GFDI)) {
communicator = new CommunicatorV1(this);
} else {
LOG.warn("Failed to find a known Garmin service");
builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.NOT_CONNECTED, getContext()));
return builder;
}
if (getDevicePrefs().getBoolean(PREF_ALLOW_HIGH_MTU, true)) {
builder.requestMtu(515);
}
communicator.initializeDevice(builder);
return builder;
}
@Override
public void onMtuChanged(final BluetoothGatt gatt, final int mtu, final int status) {
if (mtu < 23) {
LOG.warn("Ignoring mtu of {}, too low", mtu);
return;
}
if (!getDevicePrefs().getBoolean(PREF_ALLOW_HIGH_MTU, true)) {
LOG.warn("Ignoring mtu change to {} - high mtu is disabled", mtu);
return;
}
communicator.onMtuChanged(mtu);
}
@Override
public boolean onCharacteristicChanged(final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic) {
final UUID characteristicUUID = characteristic.getUuid();
if (super.onCharacteristicChanged(gatt, characteristic)) {
LOG.debug("Change of characteristic {} handled by parent", characteristicUUID);
return true;
}
return communicator.onCharacteristicChanged(gatt, characteristic);
}
@Override
public void onMessage(final byte[] message) {
if (null == message) {
return; //message is not complete yet TODO check before calling
}
// LOG.debug("COBS decoded MESSAGE: {}", GB.hexdump(message));
GFDIMessage parsedMessage = GFDIMessage.parseIncoming(message);
if (null == parsedMessage) {
return; //message cannot be handled
}
/*
the handler elaborates the followup message but might change the status message since it does
check the integrity of the incoming message payload. Hence we let the handlers elaborate the
incoming message, then we send the status message of the incoming message, then the response
and finally we send the followup.
*/
GFDIMessage followup = null;
for (MessageHandler han : messageHandlers) {
followup = han.handle(parsedMessage);
if (followup != null) {
break;
}
}
final List<GBDeviceEvent> events = parsedMessage.getGBDeviceEvent();
for (final GBDeviceEvent event : events) {
evaluateGBDeviceEvent(event);
}
communicator.sendMessage(parsedMessage.getAckBytestream()); //send status message
sendOutgoingMessage(parsedMessage); //send reply if any
sendOutgoingMessage(followup); //send followup message if any
if (parsedMessage instanceof ConfigurationMessage) { //the last forced message exchange
completeInitialization();
}
processDownloadQueue();
}
@Override
public void onSetCallState(CallSpec callSpec) {
LOG.info("INCOMING CALLSPEC: {}", callSpec.command);
sendOutgoingMessage(notificationsHandler.onSetCallState(callSpec));
}
@Override
public void evaluateGBDeviceEvent(GBDeviceEvent deviceEvent) {
if (deviceEvent instanceof WeatherRequestDeviceEvent) {
WeatherSpec weather = Weather.getInstance().getWeatherSpec();
if (weather != null) {
sendWeatherConditions(weather);
}
} else if (deviceEvent instanceof NotificationSubscriptionDeviceEvent) {
final boolean enable = ((NotificationSubscriptionDeviceEvent) deviceEvent).enable;
notificationsHandler.setEnabled(enable);
LOG.info("NOTIFICATIONS ARE NOW {}", enable ? "ON" : "OFF");
} else if (deviceEvent instanceof SupportedFileTypesDeviceEvent) {
this.supportedFileTypeList.clear();
this.supportedFileTypeList.addAll(((SupportedFileTypesDeviceEvent) deviceEvent).getSupportedFileTypes());
} else if (deviceEvent instanceof FileDownloadedDeviceEvent) {
LOG.debug("FILE DOWNLOAD COMPLETE {}", ((FileDownloadedDeviceEvent) deviceEvent).directoryEntry.getFileName());
if (false) // delete file from watch upon successful download TODO: add device setting
sendOutgoingMessage(new SetFileFlagsMessage(((FileDownloadedDeviceEvent) deviceEvent).directoryEntry.getFileIndex(), SetFileFlagsMessage.FileFlags.ARCHIVE));
}
super.evaluateGBDeviceEvent(deviceEvent);
}
@Override
public void onFetchRecordedData(final int dataTypes) {
if (this.supportedFileTypeList.isEmpty()) {
LOG.warn("No known supported file types");
return;
}
// FIXME respect dataTypes?
sendOutgoingMessage(fileTransferHandler.initiateDownload());
}
@Override
public void onNotification(NotificationSpec notificationSpec) {
sendOutgoingMessage(notificationsHandler.onNotification(notificationSpec));
}
@Override
public void onDeleteNotification(int id) {
sendOutgoingMessage(notificationsHandler.onDeleteNotification(id));
}
@Override
public void onSendWeather(final ArrayList<WeatherSpec> weatherSpecs) { //todo: find the closest one relative to the requested lat/long
sendWeatherConditions(weatherSpecs.get(0));
}
private void sendOutgoingMessage(GFDIMessage message) {
if (message == null)
return;
communicator.sendMessage(message.getOutgoingMessage());
}
private boolean supports(final GarminCapability capability) {
return getDevicePrefs().getStringSet(GarminPreferences.PREF_GARMIN_CAPABILITIES, Collections.emptySet())
.contains(capability.name());
}
private void sendWeatherConditions(WeatherSpec weather) {
if (!supports(GarminCapability.WEATHER_CONDITIONS)) {
// Device does not support sending weather as fit
return;
}
List<RecordData> weatherData = new ArrayList<>();
List<RecordDefinition> weatherDefinitions = new ArrayList<>(3);
weatherDefinitions.add(PredefinedLocalMessage.TODAY_WEATHER_CONDITIONS.getRecordDefinition());
weatherDefinitions.add(PredefinedLocalMessage.HOURLY_WEATHER_FORECAST.getRecordDefinition());
weatherDefinitions.add(PredefinedLocalMessage.DAILY_WEATHER_FORECAST.getRecordDefinition());
sendOutgoingMessage(new nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.FitDefinitionMessage(weatherDefinitions));
try {
RecordData today = new RecordData(PredefinedLocalMessage.TODAY_WEATHER_CONDITIONS.getRecordDefinition());
today.setFieldByName("weather_report", 0); // 0 = current, 1 = hourly_forecast, 2 = daily_forecast
today.setFieldByName("timestamp", weather.timestamp);
today.setFieldByName("observed_at_time", weather.timestamp);
today.setFieldByName("temperature", weather.currentTemp);
today.setFieldByName("low_temperature", weather.todayMinTemp);
today.setFieldByName("high_temperature", weather.todayMaxTemp);
today.setFieldByName("condition", weather.currentConditionCode);
today.setFieldByName("wind_direction", weather.windDirection);
today.setFieldByName("precipitation_probability", weather.precipProbability);
today.setFieldByName("wind_speed", Math.round(weather.windSpeed));
today.setFieldByName("temperature_feels_like", weather.feelsLikeTemp);
today.setFieldByName("relative_humidity", weather.currentHumidity);
today.setFieldByName("observed_location_lat", weather.latitude);
today.setFieldByName("observed_location_long", weather.longitude);
today.setFieldByName("location", weather.location);
weatherData.add(today);
for (int hour = 0; hour <= 11; hour++) {
if (hour < weather.hourly.size()) {
WeatherSpec.Hourly hourly = weather.hourly.get(hour);
RecordData weatherHourlyForecast = new RecordData(PredefinedLocalMessage.HOURLY_WEATHER_FORECAST.getRecordDefinition());
weatherHourlyForecast.setFieldByName("weather_report", 1); // 0 = current, 1 = hourly_forecast, 2 = daily_forecast
weatherHourlyForecast.setFieldByName("timestamp", hourly.timestamp);
weatherHourlyForecast.setFieldByName("temperature", hourly.temp);
weatherHourlyForecast.setFieldByName("condition", hourly.conditionCode);
weatherHourlyForecast.setFieldByName("wind_direction", hourly.windDirection);
weatherHourlyForecast.setFieldByName("wind_speed", Math.round(hourly.windSpeed));
weatherHourlyForecast.setFieldByName("precipitation_probability", hourly.precipProbability);
weatherHourlyForecast.setFieldByName("relative_humidity", hourly.humidity);
// weatherHourlyForecast.setFieldByName("dew_point", 0); // dew_point sint8
weatherHourlyForecast.setFieldByName("uv_index", hourly.uvIndex);
// weatherHourlyForecast.setFieldByName("air_quality", 0); // air_quality enum
weatherData.add(weatherHourlyForecast);
}
}
//
RecordData todayDailyForecast = new RecordData(PredefinedLocalMessage.DAILY_WEATHER_FORECAST.getRecordDefinition());
todayDailyForecast.setFieldByName("weather_report", 2); // 0 = current, 1 = hourly_forecast, 2 = daily_forecast
todayDailyForecast.setFieldByName("timestamp", weather.timestamp);
todayDailyForecast.setFieldByName("low_temperature", weather.todayMinTemp);
todayDailyForecast.setFieldByName("high_temperature", weather.todayMaxTemp);
todayDailyForecast.setFieldByName("condition", weather.currentConditionCode);
todayDailyForecast.setFieldByName("precipitation_probability", weather.precipProbability);
todayDailyForecast.setFieldByName("day_of_week", weather.timestamp);
weatherData.add(todayDailyForecast);
for (int day = 0; day < 4; day++) {
if (day < weather.forecasts.size()) {
WeatherSpec.Daily daily = weather.forecasts.get(day);
int ts = weather.timestamp + (day + 1) * 24 * 60 * 60; //TODO: is this needed?
RecordData weatherDailyForecast = new RecordData(PredefinedLocalMessage.DAILY_WEATHER_FORECAST.getRecordDefinition());
weatherDailyForecast.setFieldByName("weather_report", 2); // 0 = current, 1 = hourly_forecast, 2 = daily_forecast
weatherDailyForecast.setFieldByName("timestamp", weather.timestamp);
weatherDailyForecast.setFieldByName("low_temperature", daily.minTemp);
weatherDailyForecast.setFieldByName("high_temperature", daily.maxTemp);
weatherDailyForecast.setFieldByName("condition", daily.conditionCode);
weatherDailyForecast.setFieldByName("precipitation_probability", daily.precipProbability);
weatherDailyForecast.setFieldByName("day_of_week", ts);
weatherData.add(weatherDailyForecast);
}
}
sendOutgoingMessage(new nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.FitDataMessage(weatherData));
} catch (Exception e) {
LOG.error(e.getMessage());
}
}
private void completeInitialization() {
onSetTime();
enableWeather();
//following is needed for vivomove style
sendOutgoingMessage(new SystemEventMessage(SystemEventMessage.GarminSystemEventType.SYNC_READY, 0));
enableBatteryLevelUpdate();
gbDevice.setState(GBDevice.State.INITIALIZED);
gbDevice.sendDeviceUpdateIntent(getContext());
sendOutgoingMessage(new SupportedFileTypesMessage());
sendOutgoingMessage(toggleDefaultReplySuffix(getDevicePrefs().getBoolean(PREF_GARMIN_DEFAULT_REPLY_SUFFIX, true)));
}
private ProtobufMessage toggleDefaultReplySuffix(boolean value) {
final GdiSettingsService.SettingsService.Builder enableSignature = GdiSettingsService.SettingsService.newBuilder()
.setChangeRequest(
GdiSettingsService.ChangeRequest.newBuilder()
.setPointer1(65566) //TODO: this might be device specific, tested on Instinct 2s
.setPointer2(3) //TODO: this might be device specific, tested on Instinct 2s
.setEnable(GdiSettingsService.ChangeRequest.Switch.newBuilder().setValue(value)));
return protocolBufferHandler.prepareProtobufRequest(
GdiSmartProto.Smart.newBuilder()
.setSettingsService(enableSignature).build());
}
@Override
public void onSendConfiguration(String config) {
if (PREF_GARMIN_DEFAULT_REPLY_SUFFIX.equals(config)) {
sendOutgoingMessage(toggleDefaultReplySuffix(getDevicePrefs().getBoolean(PREF_GARMIN_DEFAULT_REPLY_SUFFIX, true)));
}
}
private void processDownloadQueue() {
if (!filesToDownload.isEmpty() && !fileTransferHandler.isDownloading()) {
if (!gbDevice.isBusy()) {
GB.updateTransferNotification(getContext().getString(R.string.busy_task_fetch_activity_data), "", true, 0, getContext());
getDevice().setBusyTask(getContext().getString(R.string.busy_task_fetch_activity_data));
getDevice().sendDeviceUpdateIntent(getContext());
}
try {
FileTransferHandler.DirectoryEntry directoryEntry = filesToDownload.remove();
while (checkFileExists(directoryEntry.getFileName())) {
LOG.debug("File: {} already downloaded, not downloading again.", directoryEntry.getFileName());
if (false) // delete file from watch if already downloaded TODO: add device setting
sendOutgoingMessage(new SetFileFlagsMessage(directoryEntry.getFileIndex(), SetFileFlagsMessage.FileFlags.ARCHIVE));
directoryEntry = filesToDownload.remove();
}
DownloadRequestMessage downloadRequestMessage = fileTransferHandler.downloadDirectoryEntry(directoryEntry);
if (downloadRequestMessage != null) {
sendOutgoingMessage(downloadRequestMessage);
} else {
LOG.debug("File: {} already downloaded, not downloading again, from inside.", directoryEntry.getFileName());
}
} catch (NoSuchElementException e) {
// we ran out of files to download
// FIXME this is ugly
if (gbDevice.isBusy() && gbDevice.getBusyTask().equals(getContext().getString(R.string.busy_task_fetch_activity_data))) {
getDevice().unsetBusyTask();
GB.updateTransferNotification(null, "", false, 100, getContext());
getDevice().sendDeviceUpdateIntent(getContext());
}
}
} else if (filesToDownload.isEmpty() && !fileTransferHandler.isDownloading()) {
if (gbDevice.isBusy() && gbDevice.getBusyTask().equals(getContext().getString(R.string.busy_task_fetch_activity_data))) {
getDevice().unsetBusyTask();
GB.updateTransferNotification(null, "", false, 100, getContext());
getDevice().sendDeviceUpdateIntent(getContext());
}
}
}
private void enableBatteryLevelUpdate() {
final ProtobufMessage batteryLevelProtobufRequest = protocolBufferHandler.prepareProtobufRequest(GdiSmartProto.Smart.newBuilder()
.setDeviceStatusService(
GdiDeviceStatus.DeviceStatusService.newBuilder()
.setRemoteDeviceBatteryStatusRequest(
GdiDeviceStatus.DeviceStatusService.RemoteDeviceBatteryStatusRequest.newBuilder()
)
)
.build());
sendOutgoingMessage(batteryLevelProtobufRequest);
}
private void enableWeather() {
final Map<SetDeviceSettingsMessage.GarminDeviceSetting, Object> settings = new LinkedHashMap<>(3);
settings.put(SetDeviceSettingsMessage.GarminDeviceSetting.AUTO_UPLOAD_ENABLED, false);
settings.put(SetDeviceSettingsMessage.GarminDeviceSetting.WEATHER_CONDITIONS_ENABLED, true);
settings.put(SetDeviceSettingsMessage.GarminDeviceSetting.WEATHER_ALERTS_ENABLED, false);
sendOutgoingMessage(new SetDeviceSettingsMessage(settings));
}
@Override
public void onSetTime() {
sendOutgoingMessage(new SystemEventMessage(SystemEventMessage.GarminSystemEventType.TIME_UPDATED, 0));
}
@Override
public void onFindDevice(boolean start) {
final GdiFindMyWatch.FindMyWatchService.Builder a = GdiFindMyWatch.FindMyWatchService.newBuilder();
if (start) {
a.setFindRequest(
GdiFindMyWatch.FindMyWatchService.FindMyWatchRequest.newBuilder()
.setTimeout(60)
);
} else {
a.setCancelRequest(
GdiFindMyWatch.FindMyWatchService.FindMyWatchCancelRequest.newBuilder()
);
}
final ProtobufMessage findMyWatch = protocolBufferHandler.prepareProtobufRequest(
GdiSmartProto.Smart.newBuilder()
.setFindMyWatchService(a).build());
sendOutgoingMessage(findMyWatch);
}
@Override
public void onSetCannedMessages(CannedMessagesSpec cannedMessagesSpec) {
sendOutgoingMessage(protocolBufferHandler.setCannedMessages(cannedMessagesSpec));
}
@Override
public void onSetMusicInfo(MusicSpec musicSpec) {
Map<MusicControlEntityUpdateMessage.MusicEntity, String> attributes = new HashMap<>();
attributes.put(MusicControlEntityUpdateMessage.TRACK.ARTIST, musicSpec.artist);
attributes.put(MusicControlEntityUpdateMessage.TRACK.ALBUM, musicSpec.album);
attributes.put(MusicControlEntityUpdateMessage.TRACK.TITLE, musicSpec.track);
attributes.put(MusicControlEntityUpdateMessage.TRACK.DURATION, String.valueOf(musicSpec.duration));
sendOutgoingMessage(new MusicControlEntityUpdateMessage(attributes));
}
@Override
public void onSetMusicState(MusicStateSpec stateSpec) {
musicStateSpec = stateSpec;
stopMusicTimer();
musicStateTimer = new Timer();
int updatePeriod = 4000; //milliseconds
LOG.debug("onSetMusicState: {}", stateSpec.toString());
if (stateSpec.state == MusicStateSpec.STATE_PLAYING) {
musicStateTimer.schedule(new TimerTask() {
@Override
public void run() {
String playing = "1";
String playRate = "1.0";
String position = new DecimalFormat("#.000").format(musicStateSpec.position);
musicStateSpec.position += updatePeriod / 1000;
Map<MusicControlEntityUpdateMessage.MusicEntity, String> attributes = new HashMap<>();
attributes.put(MusicControlEntityUpdateMessage.PLAYER.PLAYBACK_INFO, StringUtils.join(",", playing, playRate, position).toString());
sendOutgoingMessage(new MusicControlEntityUpdateMessage(attributes));
}
}, 0, updatePeriod);
} else {
String playing = "0";
String playRate = "0.0";
String position = new DecimalFormat("#.###").format(stateSpec.position);
Map<MusicControlEntityUpdateMessage.MusicEntity, String> attributes = new HashMap<>();
attributes.put(MusicControlEntityUpdateMessage.PLAYER.PLAYBACK_INFO, StringUtils.join(",", playing, playRate, position).toString());
sendOutgoingMessage(new MusicControlEntityUpdateMessage(attributes));
}
}
private boolean checkFileExists(String fileName) {
File dir;
try {
dir = getWritableExportDirectory();
File outputFile = new File(dir, fileName);
if (outputFile.exists()) //do not download again already downloaded file
return true;
} catch (IOException e) {
LOG.error("IOException: " + e);
}
return false;
}
public File getWritableExportDirectory() throws IOException {
File dir;
dir = new File(FileUtils.getExternalFilesDir() + "/" + FileUtils.makeValidFileName(getDevice().getName() + "_" + getDevice().getAddress()));
if (!dir.isDirectory()) {
if (!dir.mkdir()) {
throw new IOException("Cannot create device specific directory for " + getDevice().getName());
}
}
return dir;
}
}

View File

@ -0,0 +1,29 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin;
import org.threeten.bp.Instant;
import org.threeten.bp.ZoneId;
public class GarminTimeUtils {
public static final int GARMIN_TIME_EPOCH = 631065600;
public static int unixTimeToGarminTimestamp(int unixTime) {
return unixTime - GARMIN_TIME_EPOCH;
}
public static int javaMillisToGarminTimestamp(long millis) {
return (int) (millis / 1000) - GARMIN_TIME_EPOCH;
}
public static long garminTimestampToJavaMillis(int timestamp) {
return (timestamp + GARMIN_TIME_EPOCH) * 1000L;
}
public static int garminTimestampToUnixTime(int timestamp) {
return timestamp + GARMIN_TIME_EPOCH;
}
public static int unixTimeToGarminDayOfWeek(int unixTime) {
return (Instant.ofEpochSecond(unixTime).atZone(ZoneId.systemDefault()).getDayOfWeek().getValue() % 7);
}
}

View File

@ -0,0 +1,7 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.GFDIMessage;
public interface MessageHandler {
GFDIMessage handle(GFDIMessage message);
}

View File

@ -0,0 +1,496 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin;
import android.util.SparseArray;
import org.apache.commons.lang3.EnumUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Queue;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventCallControl;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventNotificationControl;
import nodomain.freeyourgadget.gadgetbridge.model.CallSpec;
import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
import nodomain.freeyourgadget.gadgetbridge.model.NotificationType;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.GFDIMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.MessageWriter;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.NotificationControlMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.NotificationDataMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.NotificationUpdateMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.NotificationDataStatusMessage;
import nodomain.freeyourgadget.gadgetbridge.util.LimitedQueue;
public class NotificationsHandler implements MessageHandler {
public static final SimpleDateFormat NOTIFICATION_DATE_FORMAT = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.ROOT);
private static final Logger LOG = LoggerFactory.getLogger(NotificationsHandler.class);
private final Queue<NotificationSpec> notificationSpecQueue;
private final Upload upload;
private boolean enabled = false;
// Keep track of Notification ID -> action handle, as BangleJSDeviceSupport.
// TODO: This needs to be simplified.
private final LimitedQueue<Integer, Long> mNotificationReplyAction = new LimitedQueue<>(16);
public NotificationsHandler() {
this.notificationSpecQueue = new LinkedList<>();
this.upload = new Upload();
}
private static void encodeNotificationAttribute(NotificationSpec notificationSpec, Map.Entry<NotificationAttribute, Integer> entry, MessageWriter messageWriter) {
messageWriter.writeByte(entry.getKey().code);
final byte[] bytes = entry.getKey().getNotificationSpecAttribute(notificationSpec, entry.getValue());
messageWriter.writeShort(bytes.length);
messageWriter.writeBytes(bytes);
// LOG.info("ATTRIBUTE:{} value:{} length:{}", entry.getKey(), new String(bytes), bytes.length);
}
private boolean addNotificationToQueue(NotificationSpec notificationSpec) {
boolean found = false;
Iterator<NotificationSpec> iterator = notificationSpecQueue.iterator();
while (iterator.hasNext()) {
NotificationSpec e = iterator.next();
if (e.getId() == notificationSpec.getId()) {
found = true;
iterator.remove();
}
}
notificationSpecQueue.offer(notificationSpec); // Add the notificationSpec to the front of the queue
return found;
}
public NotificationUpdateMessage onSetCallState(CallSpec callSpec) {
if (!enabled)
return null;
if (callSpec.command == CallSpec.CALL_INCOMING) {
NotificationSpec callNotificationSpec = new NotificationSpec(callSpec.number.hashCode());
callNotificationSpec.phoneNumber = callSpec.number;
callNotificationSpec.sourceAppId = callSpec.sourceAppId;
callNotificationSpec.title = StringUtils.isEmpty(callSpec.name) ? callSpec.number : callSpec.name;
callNotificationSpec.type = NotificationType.GENERIC_PHONE;
callNotificationSpec.body = StringUtils.isEmpty(callSpec.name) ? callSpec.number : callSpec.name;
return onNotification(callNotificationSpec);
} else {
if (callSpec.number != null) // this happens in debug screen
return onDeleteNotification(callSpec.number.hashCode());
}
return null;
}
public NotificationUpdateMessage onNotification(NotificationSpec notificationSpec) {
if (!enabled)
return null;
final boolean isUpdate = addNotificationToQueue(notificationSpec);
NotificationUpdateMessage.NotificationUpdateType notificationUpdateType = isUpdate ? NotificationUpdateMessage.NotificationUpdateType.MODIFY : NotificationUpdateMessage.NotificationUpdateType.ADD;
if (notificationSpecQueue.size() > 10)
notificationSpecQueue.poll(); //remove the oldest notification TODO: should send a delete notification message to watch!
final boolean hasActions = (null != notificationSpec.attachedActions && !notificationSpec.attachedActions.isEmpty());
if (hasActions) {
for (int i = 0; i < notificationSpec.attachedActions.size(); i++) {
final NotificationSpec.Action action = notificationSpec.attachedActions.get(i);
if (action.type == NotificationSpec.Action.TYPE_WEARABLE_REPLY || action.type == NotificationSpec.Action.TYPE_SYNTECTIC_REPLY_PHONENR) {
mNotificationReplyAction.add(notificationSpec.getId(), action.handle);
}
}
}
return new NotificationUpdateMessage(notificationUpdateType, notificationSpec.type, getNotificationsCount(notificationSpec.type), notificationSpec.getId(), hasActions);
}
private int getNotificationsCount(NotificationType notificationType) {
int count = 0;
for (NotificationSpec e : notificationSpecQueue) {
count += e.type == notificationType ? 1 : 0;
}
return count;
}
private NotificationSpec getNotificationSpecFromQueue(int id) {
for (NotificationSpec e : notificationSpecQueue) {
if (e.getId() == id) {
return e;
}
}
return null;
}
public NotificationUpdateMessage onDeleteNotification(int id) {
if (!enabled)
return null;
Iterator<NotificationSpec> iterator = notificationSpecQueue.iterator();
while (iterator.hasNext()) {
NotificationSpec e = iterator.next();
if (e.getId() == id) {
iterator.remove();
return new NotificationUpdateMessage(NotificationUpdateMessage.NotificationUpdateType.REMOVE, e.type, getNotificationsCount(e.type), id, false);
}
}
return null;
}
public GFDIMessage handle(GFDIMessage message) {
if (!enabled)
return null;
if (message instanceof NotificationControlMessage) {
final NotificationSpec notificationSpec = getNotificationSpecFromQueue(((NotificationControlMessage) message).getNotificationId());
if (notificationSpec != null) {
switch (((NotificationControlMessage) message).getCommand()) {
case GET_NOTIFICATION_ATTRIBUTES:
return getNotificationDataMessage((NotificationControlMessage) message, notificationSpec);
case PERFORM_LEGACY_NOTIFICATION_ACTION:
LOG.info("Legacy Notification: {}", ((NotificationControlMessage) message).getLegacyNotificationAction());
break;
case PERFORM_NOTIFICATION_ACTION:
performNotificationAction((NotificationControlMessage) message, notificationSpec);
break;
default:
LOG.error("NOT SUPPORTED: {}", ((NotificationControlMessage) message).getCommand());
}
}
} else if (message instanceof NotificationDataStatusMessage) {
return upload.processUploadProgress((NotificationDataStatusMessage) message);
}
return null;
}
private void performNotificationAction(NotificationControlMessage message, NotificationSpec notificationSpec) {
final GBDeviceEventNotificationControl deviceEvtNotificationControl = new GBDeviceEventNotificationControl();
deviceEvtNotificationControl.handle = notificationSpec.getId();
final GBDeviceEventCallControl deviceEvtCallControl = new GBDeviceEventCallControl();
switch (message.getNotificationAction()) {
case REPLY_INCOMING_CALL:
case REPLY_MESSAGES:
deviceEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.REPLY;
deviceEvtNotificationControl.reply = message.getActionString();
if (notificationSpec.type.equals(NotificationType.GENERIC_PHONE)) {
deviceEvtNotificationControl.phoneNumber = notificationSpec.phoneNumber;
} else {
deviceEvtNotificationControl.handle = mNotificationReplyAction.lookup(notificationSpec.getId()); //handle of wearable action is needed
}
message.setDeviceEvent(deviceEvtNotificationControl);
break;
case ACCEPT_INCOMING_CALL:
deviceEvtCallControl.event = GBDeviceEventCallControl.Event.ACCEPT;
message.setDeviceEvent(deviceEvtCallControl);
break;
case REJECT_INCOMING_CALL:
deviceEvtCallControl.event = GBDeviceEventCallControl.Event.REJECT;
message.setDeviceEvent(deviceEvtCallControl);
break;
case DISMISS_NOTIFICATION:
deviceEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.DISMISS;
message.setDeviceEvent(deviceEvtNotificationControl);
break;
case BLOCK_APPLICATION:
deviceEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.MUTE;
message.setDeviceEvent(deviceEvtNotificationControl);
break;
}
}
private NotificationDataMessage getNotificationDataMessage(NotificationControlMessage message, NotificationSpec notificationSpec) {
final MessageWriter messageWriter = new MessageWriter();
messageWriter.writeByte(NotificationCommand.GET_NOTIFICATION_ATTRIBUTES.code);
messageWriter.writeInt(message.getNotificationId());
Map.Entry<NotificationAttribute, Integer> lastEntry = null;
for (Map.Entry<NotificationAttribute, Integer> entry : message.getNotificationAttributesMap().entrySet()) {
if (!NotificationAttribute.MESSAGE_SIZE.equals(entry.getKey())) {
encodeNotificationAttribute(notificationSpec, entry, messageWriter);
} else {
lastEntry = entry;
}
}
if (lastEntry != null) {
encodeNotificationAttribute(notificationSpec, lastEntry, messageWriter);
}
NotificationFragment notificationFragment = new NotificationFragment(messageWriter.getBytes());
return upload.setCurrentlyUploading(notificationFragment);
}
public void setEnabled(boolean enable) {
this.enabled = enable;
}
public enum NotificationCommand { //was AncsCommand
GET_NOTIFICATION_ATTRIBUTES(0),
GET_APP_ATTRIBUTES(1), //unknown/untested
PERFORM_LEGACY_NOTIFICATION_ACTION(2),
PERFORM_NOTIFICATION_ACTION(128);
public final int code;
NotificationCommand(int code) {
this.code = code;
}
public static NotificationCommand fromCode(int code) {
for (NotificationCommand value : values()) {
if (value.code == code)
return value;
}
throw new IllegalArgumentException("Unknown notification command " + code);
}
}
public enum LegacyNotificationAction { //was AncsAction
ACCEPT,
REFUSE
}
public enum NotificationAttribute { //was AncsAttribute
APP_IDENTIFIER(0),
TITLE(1, true),
SUBTITLE(2, true),
MESSAGE(3, true),
MESSAGE_SIZE(4),
DATE(5),
// POSITIVE_ACTION_LABEL(6), //needed only for legacy notification actions
// NEGATIVE_ACTION_LABEL(7), //needed only for legacy notification actions
// Garmin extensions
// PHONE_NUMBER(126, true),
ACTIONS(127, false, true),
;
private static final SparseArray<NotificationAttribute> valueByCode;
static {
final NotificationAttribute[] values = values();
valueByCode = new SparseArray<>(values.length);
for (NotificationAttribute value : values) {
valueByCode.append(value.code, value);
}
}
public final int code;
public final boolean hasLengthParam;
public final boolean hasAdditionalParams;
NotificationAttribute(int code) {
this(code, false, false);
}
NotificationAttribute(int code, boolean hasLengthParam) {
this(code, hasLengthParam, false);
}
NotificationAttribute(int code, boolean hasLengthParam, boolean hasAdditionalParams) {
this.code = code;
this.hasLengthParam = hasLengthParam;
this.hasAdditionalParams = hasAdditionalParams;
}
public static NotificationAttribute getByCode(int code) {
return valueByCode.get(code);
}
public byte[] getNotificationSpecAttribute(NotificationSpec notificationSpec, int maxLength) {
String toReturn = "";
switch (this) {
case DATE:
final long notificationTimestamp = notificationSpec.when == 0 ? System.currentTimeMillis() : notificationSpec.when;
toReturn = NOTIFICATION_DATE_FORMAT.format(new Date(notificationTimestamp));
break;
case TITLE:
toReturn = notificationSpec.title == null ? "" : notificationSpec.title;
break;
case SUBTITLE:
toReturn = notificationSpec.subject == null ? "" : notificationSpec.subject;
break;
case APP_IDENTIFIER:
toReturn = notificationSpec.sourceAppId == null ? "" : notificationSpec.sourceAppId;
break;
case MESSAGE:
toReturn = notificationSpec.body == null ? "" : notificationSpec.body;
break;
case MESSAGE_SIZE:
toReturn = Integer.toString(notificationSpec.body == null ? "".length() : notificationSpec.body.length());
break;
case ACTIONS:
toReturn = encodeNotificationActionsString(notificationSpec);
break;
}
if (maxLength == 0)
return toReturn.getBytes(StandardCharsets.UTF_8);
return toReturn.substring(0, Math.min(toReturn.length(), maxLength)).getBytes(StandardCharsets.UTF_8);
}
private String encodeNotificationActionsString(NotificationSpec notificationSpec) {
final List<byte[]> garminActions = new ArrayList<>();
if (notificationSpec.type.equals(NotificationType.GENERIC_PHONE)) {
garminActions.add(encodeNotificationAction(NotificationAction.REPLY_INCOMING_CALL, " ")); //text is not shown on watch
garminActions.add(encodeNotificationAction(NotificationAction.REJECT_INCOMING_CALL, " ")); //text is not shown on watch
garminActions.add(encodeNotificationAction(NotificationAction.ACCEPT_INCOMING_CALL, " ")); //text is not shown on watch
}
if (null != notificationSpec.attachedActions) {
for (NotificationSpec.Action action : notificationSpec.attachedActions) {
switch (action.type) {
case NotificationSpec.Action.TYPE_WEARABLE_REPLY:
garminActions.add(encodeNotificationAction(NotificationAction.REPLY_MESSAGES, action.title));
break;
case NotificationSpec.Action.TYPE_SYNTECTIC_DISMISS:
garminActions.add(encodeNotificationAction(NotificationAction.DISMISS_NOTIFICATION, action.title));
break;
case NotificationSpec.Action.TYPE_SYNTECTIC_MUTE:
garminActions.add(encodeNotificationAction(NotificationAction.BLOCK_APPLICATION, action.title));
break;
}
// LOG.info("Notification has action {} with title {}", action.type, action.title);
}
}
if (garminActions.isEmpty())
return new String(new byte[]{0x00, 0x00, 0x00, 0x00});
try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
byteArrayOutputStream.write(garminActions.size());
for (byte[] item : garminActions) {
byteArrayOutputStream.write(item);
}
return byteArrayOutputStream.toString();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private byte[] encodeNotificationAction(NotificationAction notificationAction, String description) {
final ByteBuffer action = ByteBuffer.allocate(3 + description.getBytes(StandardCharsets.UTF_8).length);
action.put((byte) notificationAction.code);
if (null == notificationAction.notificationActionIconPosition)
action.put((byte) 0x00);
else
action.put((byte) EnumUtils.generateBitVector(NotificationActionIconPosition.class, notificationAction.notificationActionIconPosition));
action.put((byte) description.getBytes(StandardCharsets.UTF_8).length);
action.put(description.getBytes());
return action.array();
}
}
public enum NotificationAction {
REPLY_INCOMING_CALL(94, NotificationActionIconPosition.BOTTOM),
REPLY_MESSAGES(95, NotificationActionIconPosition.BOTTOM),
ACCEPT_INCOMING_CALL(96, NotificationActionIconPosition.RIGHT),
REJECT_INCOMING_CALL(97, NotificationActionIconPosition.LEFT),
DISMISS_NOTIFICATION(98, NotificationActionIconPosition.LEFT),
BLOCK_APPLICATION(99, null),
;
private final int code;
private final NotificationActionIconPosition notificationActionIconPosition;
NotificationAction(int code, NotificationActionIconPosition notificationActionIconPosition) {
this.code = code;
this.notificationActionIconPosition = notificationActionIconPosition;
}
public static NotificationAction fromCode(final int code) {
for (final NotificationAction notificationAction : NotificationAction.values()) {
if (notificationAction.code == code) {
return notificationAction;
}
}
throw new IllegalArgumentException("Unknown notification action code " + code);
}
}
enum NotificationActionIconPosition { //educated guesses based on the icons' positions on vĂ­vomove style
BOTTOM, //or is it reply?
RIGHT, //or is it accept?
LEFT, //or is it dismiss/refuse?
}
public static class Upload {
private NotificationFragment currentlyUploading;
public NotificationDataMessage setCurrentlyUploading(NotificationFragment currentlyUploading) {
this.currentlyUploading = currentlyUploading;
return currentlyUploading.take();
}
private GFDIMessage processUploadProgress(NotificationDataStatusMessage notificationDataStatusMessage) {
if (null == currentlyUploading) {
LOG.warn("Received Upload Progress but we are not sending any notification");
return null;
}
if (!currentlyUploading.dataHolder.hasRemaining()) {
this.currentlyUploading = null;
LOG.info("SENT ALL");
return new NotificationDataStatusMessage(GFDIMessage.GarminMessage.NOTIFICATION_DATA, GFDIMessage.Status.ACK, NotificationDataStatusMessage.TransferStatus.OK);
} else {
if (notificationDataStatusMessage.canProceed()) {
LOG.info("SENDING NEXT CHUNK!!!");
return currentlyUploading.take();
} else {
LOG.warn("Cannot proceed with upload"); //TODO: send the correct status message
this.currentlyUploading = null;
}
}
return null;
}
}
public static class NotificationFragment {
private final int dataSize;
private final ByteBuffer dataHolder;
private final int maxBlockSize = 300;
private int runningCrc;
NotificationFragment(byte[] contents) {
this.dataHolder = ByteBuffer.wrap(contents);
this.dataSize = contents.length;
this.dataHolder.flip();
this.dataHolder.compact();
this.setRunningCrc(0);
}
public int getDataSize() {
return dataSize;
}
private int getMaxBlockSize() {
return maxBlockSize;
}
private NotificationDataMessage take() {
final int currentOffset = this.dataHolder.position();
final byte[] chunk = new byte[Math.min(this.dataHolder.remaining(), getMaxBlockSize())];
this.dataHolder.get(chunk);
setRunningCrc(ChecksumCalculator.computeCrc(getRunningCrc(), chunk, 0, chunk.length));
return new NotificationDataMessage(chunk, getDataSize(), currentOffset, getRunningCrc());
}
private int getRunningCrc() {
return runningCrc;
}
private void setRunningCrc(int runningCrc) {
this.runningCrc = runningCrc;
}
}
}

View File

@ -0,0 +1,423 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin;
import com.google.protobuf.InvalidProtocolBufferException;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo;
import nodomain.freeyourgadget.gadgetbridge.model.CannedMessagesSpec;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiCalendarService;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiCore;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiDataTransferService;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiDeviceStatus;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiFindMyWatch;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiHttpService;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiSmartProto;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiSmsNotification;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.http.DataTransferHandler;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.http.HttpHandler;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.GFDIMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.ProtobufMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.ProtobufStatusMessage;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.calendar.CalendarEvent;
import nodomain.freeyourgadget.gadgetbridge.util.calendar.CalendarManager;
public class ProtocolBufferHandler implements MessageHandler {
private static final Logger LOG = LoggerFactory.getLogger(ProtocolBufferHandler.class);
private final GarminSupport deviceSupport;
private final Map<Integer, ProtobufFragment> chunkedFragmentsMap;
private final int maxChunkSize = 375; //tested on VĂ­vomove Style
private int lastProtobufRequestId;
private final HttpHandler httpHandler;
private final DataTransferHandler dataTransferHandler;
private Map<GdiSmsNotification.SmsNotificationService.CannedListType, String[]> cannedListTypeMap;
public ProtocolBufferHandler(GarminSupport deviceSupport) {
this.deviceSupport = deviceSupport;
chunkedFragmentsMap = new HashMap<>();
httpHandler = new HttpHandler(deviceSupport);
dataTransferHandler = new DataTransferHandler();
}
private int getNextProtobufRequestId() {
lastProtobufRequestId = (lastProtobufRequestId + 1) % 65536;
return lastProtobufRequestId;
}
public ProtobufMessage handle(GFDIMessage protobufMessage) {
if (protobufMessage instanceof ProtobufMessage) {
return processIncoming((ProtobufMessage) protobufMessage);
} else if (protobufMessage instanceof ProtobufStatusMessage) {
return processIncoming((ProtobufStatusMessage) protobufMessage);
}
return null;
}
private ProtobufMessage processIncoming(ProtobufMessage message) {
ProtobufFragment protobufFragment = processChunkedMessage(message);
if (protobufFragment.isComplete()) { //message is now complete
LOG.info("Received protobuf message #{}, {}B: {}", message.getRequestId(), protobufFragment.totalLength, GB.hexdump(protobufFragment.fragmentBytes, 0, protobufFragment.totalLength));
final GdiSmartProto.Smart smart;
try {
smart = GdiSmartProto.Smart.parseFrom(protobufFragment.fragmentBytes);
} catch (InvalidProtocolBufferException e) {
LOG.error("Failed to parse protobuf message ({}): {}", e.getLocalizedMessage(), GB.hexdump(protobufFragment.fragmentBytes));
return null;
}
boolean processed = false;
if (smart.hasCoreService()) { //TODO: unify request and response???
processed = true;
processProtobufCoreResponse(smart.getCoreService());
// return prepareProtobufResponse(processProtobufCoreRequest(smart.getCoreService()), message.getRequestId());
}
if (smart.hasCalendarService()) {
return prepareProtobufResponse(processProtobufCalendarRequest(smart.getCalendarService()), message.getRequestId());
}
if (smart.hasSmsNotificationService()) {
return prepareProtobufResponse(processProtobufSmsNotificationMessage(smart.getSmsNotificationService()), message.getRequestId());
}
if (smart.hasHttpService()) {
final GdiHttpService.HttpService response = httpHandler.handle(smart.getHttpService());
if (response == null) {
return null;
}
return prepareProtobufResponse(GdiSmartProto.Smart.newBuilder().setHttpService(response).build(), message.getRequestId());
}
if (smart.hasDataTransferService()) {
final GdiDataTransferService.DataTransferService response = dataTransferHandler.handle(smart.getDataTransferService(), message.getRequestId());
if (response == null) {
return null;
}
return prepareProtobufResponse(GdiSmartProto.Smart.newBuilder().setDataTransferService(response).build(), message.getRequestId());
}
if (smart.hasDeviceStatusService()) {
processed = true;
processProtobufDeviceStatusResponse(smart.getDeviceStatusService());
}
if (smart.hasFindMyWatchService()) {
processed = true;
processProtobufFindMyWatchResponse(smart.getFindMyWatchService());
}
if (!processed) {
LOG.warn("Unknown protobuf request: {}", smart);
message.setStatusMessage(new ProtobufStatusMessage(message.getMessageType(), GFDIMessage.Status.ACK, message.getRequestId(), message.getDataOffset(), ProtobufStatusMessage.ProtobufChunkStatus.DISCARDED, ProtobufStatusMessage.ProtobufStatusCode.UNKNOWN_REQUEST_ID));
}
}
return null;
}
private ProtobufMessage processIncoming(ProtobufStatusMessage statusMessage) {
LOG.info("Processing protobuf status message #{}@{}: status={}, error={}", statusMessage.getRequestId(), statusMessage.getDataOffset(), statusMessage.getProtobufChunkStatus(), statusMessage.getProtobufStatusCode());
//TODO: check status and react accordingly, right now we blindly proceed to next chunk
if (statusMessage.isOK()) {
DataTransferHandler.onDataChunkSuccessfullyReceived(statusMessage.getRequestId());
}
if (chunkedFragmentsMap.containsKey(statusMessage.getRequestId()) && statusMessage.isOK()) {
final ProtobufFragment protobufFragment = chunkedFragmentsMap.get(statusMessage.getRequestId());
LOG.debug("Protobuf message #{} found in queue: {}", statusMessage.getRequestId(), GB.hexdump(protobufFragment.fragmentBytes));
if (protobufFragment.totalLength <= (statusMessage.getDataOffset() + maxChunkSize)) {
chunkedFragmentsMap.remove(protobufFragment);
}
return protobufFragment.getNextChunk(statusMessage);
}
return null;
}
private ProtobufFragment processChunkedMessage(ProtobufMessage message) {
if (message.isComplete()) //comment this out if for any reason also smaller messages should end up in the map
return new ProtobufFragment(message.getMessageBytes());
if (message.getDataOffset() == 0) { //store new messages beginning at 0, overwrite old messages
chunkedFragmentsMap.put(message.getRequestId(), new ProtobufFragment(message));
LOG.info("Protobuf request put in queue: #{} , {}", message.getRequestId(), GB.hexdump(message.getMessageBytes()));
} else {
if (chunkedFragmentsMap.containsKey(message.getRequestId())) {
ProtobufFragment oldFragment = chunkedFragmentsMap.get(message.getRequestId());
chunkedFragmentsMap.put(message.getRequestId(),
new ProtobufFragment(oldFragment, message));
}
}
return chunkedFragmentsMap.get(message.getRequestId());
}
private GdiSmartProto.Smart processProtobufCalendarRequest(GdiCalendarService.CalendarService calendarService) {
if (calendarService.hasCalendarRequest()) {
GdiCalendarService.CalendarService.CalendarServiceRequest calendarServiceRequest = calendarService.getCalendarRequest();
CalendarManager upcomingEvents = new CalendarManager(deviceSupport.getContext(), deviceSupport.getDevice().getAddress());
List<CalendarEvent> mEvents = upcomingEvents.getCalendarEventList();
List<GdiCalendarService.CalendarService.CalendarEvent> watchEvents = new ArrayList<>();
for (CalendarEvent mEvt : mEvents) {
if (mEvt.getEndSeconds() < calendarServiceRequest.getBegin() ||
mEvt.getBeginSeconds() > calendarServiceRequest.getEnd()) {
LOG.debug("CalendarService Skipping event {} that is out of requested time range", mEvt.getTitle());
continue;
}
watchEvents.add(GdiCalendarService.CalendarService.CalendarEvent.newBuilder()
.setTitle(mEvt.getTitle())
.setAllDay(mEvt.isAllDay())
.setBegin(mEvt.getBeginSeconds())
.setEnd(mEvt.getEndSeconds())
.setLocation(StringUtils.defaultString(mEvt.getLocation()))
.setDescription(StringUtils.defaultString(mEvt.getDescription()))
.build()
);
}
LOG.debug("CalendarService Sending {} events to watch", watchEvents.size());
return GdiSmartProto.Smart.newBuilder().setCalendarService(
GdiCalendarService.CalendarService.newBuilder().setCalendarResponse(
GdiCalendarService.CalendarService.CalendarServiceResponse.newBuilder()
.addAllCalendarEvent(watchEvents)
.setUnknown(1)
)
).build();
}
LOG.warn("Unknown CalendarService request: {}", calendarService);
return GdiSmartProto.Smart.newBuilder().setCalendarService(
GdiCalendarService.CalendarService.newBuilder().setCalendarResponse(
GdiCalendarService.CalendarService.CalendarServiceResponse.newBuilder()
.setUnknown(0)
)
).build();
}
private void processProtobufCoreResponse(GdiCore.CoreService coreService) {
if (coreService.hasSyncResponse()) {
final GdiCore.CoreService.SyncResponse syncResponse = coreService.getSyncResponse();
LOG.info("Received sync status: {}", syncResponse.getStatus());
}
LOG.warn("Unknown CoreService response: {}", coreService);
}
private void processProtobufDeviceStatusResponse(GdiDeviceStatus.DeviceStatusService deviceStatusService) {
if (deviceStatusService.hasRemoteDeviceBatteryStatusResponse()) {
final GdiDeviceStatus.DeviceStatusService.RemoteDeviceBatteryStatusResponse batteryStatusResponse = deviceStatusService.getRemoteDeviceBatteryStatusResponse();
final int batteryLevel = batteryStatusResponse.getCurrentBatteryLevel();
LOG.info("Received remote battery status {}: level={}", batteryStatusResponse.getStatus(), batteryLevel);
final GBDeviceEventBatteryInfo batteryEvent = new GBDeviceEventBatteryInfo();
batteryEvent.level = (short) batteryLevel;
deviceSupport.evaluateGBDeviceEvent(batteryEvent);
return;
}
if (deviceStatusService.hasActivityStatusResponse()) {
final GdiDeviceStatus.DeviceStatusService.ActivityStatusResponse activityStatusResponse = deviceStatusService.getActivityStatusResponse();
LOG.info("Received activity status: {}", activityStatusResponse.getStatus());
return;
}
LOG.warn("Unknown DeviceStatusService response: {}", deviceStatusService);
}
// private GdiSmartProto.Smart processProtobufCoreRequest(GdiCore.CoreService coreService) {
// if (coreService.hasLocationUpdatedSetEnabledRequest()) { //TODO: enable location support in devicesupport
// LOG.debug("Location CoreService: {}", coreService);
//
// final GdiCore.CoreService.LocationUpdatedSetEnabledRequest locationUpdatedSetEnabledRequest = coreService.getLocationUpdatedSetEnabledRequest();
//
// LOG.info("Received locationUpdatedSetEnabledRequest status: {}", locationUpdatedSetEnabledRequest.getEnabled());
//
// GdiCore.CoreService.LocationUpdatedSetEnabledResponse.Builder response = GdiCore.CoreService.LocationUpdatedSetEnabledResponse.newBuilder()
// .setStatus(GdiCore.CoreService.LocationUpdatedSetEnabledResponse.Status.OK);
//
// //TODO: check and follow the preference in coordinator (see R.xml.devicesettings_workout_send_gps_to_band )
// if(locationUpdatedSetEnabledRequest.getEnabled()) {
// response.addRequests(GdiCore.CoreService.LocationUpdatedSetEnabledResponse.Requested.newBuilder()
// .setRequested(locationUpdatedSetEnabledRequest.getRequests(0).getRequested())
// .setStatus(GdiCore.CoreService.LocationUpdatedSetEnabledResponse.Requested.RequestedStatus.OK));
// }
//
// deviceSupport.processLocationUpdateRequest(locationUpdatedSetEnabledRequest.getEnabled(), locationUpdatedSetEnabledRequest.getRequestsList());
//
// return GdiSmartProto.Smart.newBuilder().setCoreService(
// GdiCore.CoreService.newBuilder().setLocationUpdatedSetEnabledResponse(response)).build();
// }
// LOG.warn("Unknown CoreService request: {}", coreService);
// return null;
// }
private GdiSmartProto.Smart processProtobufSmsNotificationMessage(GdiSmsNotification.SmsNotificationService smsNotificationService) {
if (smsNotificationService.hasSmsCannedListRequest()) {
if (null == this.cannedListTypeMap || this.cannedListTypeMap.isEmpty()) {
this.cannedListTypeMap = new HashMap<>();
List<GdiSmsNotification.SmsNotificationService.CannedListType> requestedTypes = smsNotificationService.getSmsCannedListRequest().getRequestedTypesList();
for (GdiSmsNotification.SmsNotificationService.CannedListType type :
requestedTypes) {
if (GdiSmsNotification.SmsNotificationService.CannedListType.SMS_MESSAGE_RESPONSE.equals(type)) {
final ArrayList<String> messages = new ArrayList<>();
for (int i = 1; i <= 16; i++) {
String message = deviceSupport.getDevicePrefs().getString("canned_reply_" + i, null);
if (message != null && !message.isEmpty()) {
messages.add(message);
}
}
if (!messages.isEmpty())
this.cannedListTypeMap.put(type, messages.toArray(new String[0]));
} else if (GdiSmsNotification.SmsNotificationService.CannedListType.PHONE_CALL_RESPONSE.equals(type)) {
final ArrayList<String> messages = new ArrayList<>();
for (int i = 1; i <= 16; i++) {
String message = deviceSupport.getDevicePrefs().getString("canned_message_dismisscall_" + i, null);
if (message != null && !message.isEmpty()) {
messages.add(message);
}
}
if (!messages.isEmpty())
this.cannedListTypeMap.put(type, messages.toArray(new String[0]));
}
}
}
List<GdiSmsNotification.SmsNotificationService.CannedListType> requestedTypes = smsNotificationService.getSmsCannedListRequest().getRequestedTypesList();
GdiSmsNotification.SmsNotificationService.SmsCannedListResponse.Builder builder = GdiSmsNotification.SmsNotificationService.SmsCannedListResponse.newBuilder()
.setStatus(GdiSmsNotification.SmsNotificationService.ResponseStatus.SUCCESS);
for (GdiSmsNotification.SmsNotificationService.CannedListType requestedType : requestedTypes) {
if (this.cannedListTypeMap.containsKey(requestedType)) {
builder.addLists(GdiSmsNotification.SmsNotificationService.SmsCannedList.newBuilder()
.addAllResponse(Arrays.asList(this.cannedListTypeMap.get(requestedType)))
.setType(requestedType)
);
} else {
builder.setStatus(GdiSmsNotification.SmsNotificationService.ResponseStatus.GENERIC_ERROR);
LOG.error("Missing canned messages data for type {}", requestedType);
}
}
return GdiSmartProto.Smart.newBuilder().setSmsNotificationService(GdiSmsNotification.SmsNotificationService.newBuilder().setSmsCannedListResponse(builder)).build();
} else {
LOG.warn("Protobuf smsNotificationService request not implemented: {}", smsNotificationService);
return null;
}
}
private void processProtobufFindMyWatchResponse(GdiFindMyWatch.FindMyWatchService findMyWatchService) {
if (findMyWatchService.hasCancelRequest()) {
LOG.info("Watch found");
}
if (findMyWatchService.hasCancelResponse() || findMyWatchService.hasFindResponse()) {
LOG.debug("Received findMyWatch response");
}
LOG.warn("Unknown FindMyWatchService response: {}", findMyWatchService);
}
public ProtobufMessage prepareProtobufRequest(GdiSmartProto.Smart protobufPayload) {
if (null == protobufPayload)
return null;
final int requestId = getNextProtobufRequestId();
return prepareProtobufMessage(protobufPayload.toByteArray(), GFDIMessage.GarminMessage.PROTOBUF_REQUEST, requestId);
}
private ProtobufMessage prepareProtobufResponse(GdiSmartProto.Smart protobufPayload, int requestId) {
if (null == protobufPayload)
return null;
return prepareProtobufMessage(protobufPayload.toByteArray(), GFDIMessage.GarminMessage.PROTOBUF_RESPONSE, requestId);
}
private ProtobufMessage prepareProtobufMessage(byte[] bytes, GFDIMessage.GarminMessage garminMessage, int requestId) {
if (bytes == null || bytes.length == 0)
return null;
LOG.info("Preparing protobuf message. Type{}, #{}, {}B: {}", garminMessage, requestId, bytes.length, GB.hexdump(bytes, 0, bytes.length));
if (bytes.length > maxChunkSize) {
chunkedFragmentsMap.put(requestId, new ProtobufFragment(bytes));
return new ProtobufMessage(garminMessage,
requestId,
0,
bytes.length,
maxChunkSize,
ArrayUtils.subarray(bytes, 0, maxChunkSize));
}
return new ProtobufMessage(garminMessage, requestId, 0, bytes.length, bytes.length, bytes);
}
public ProtobufMessage setCannedMessages(CannedMessagesSpec cannedMessagesSpec) {
final GdiSmsNotification.SmsNotificationService.CannedListType cannedListType;
switch (cannedMessagesSpec.type) {
case CannedMessagesSpec.TYPE_REJECTEDCALLS:
cannedListType = GdiSmsNotification.SmsNotificationService.CannedListType.PHONE_CALL_RESPONSE;
break;
case CannedMessagesSpec.TYPE_GENERIC:
case CannedMessagesSpec.TYPE_NEWSMS:
cannedListType = GdiSmsNotification.SmsNotificationService.CannedListType.SMS_MESSAGE_RESPONSE;
break;
default:
LOG.warn("Unknown canned messages type, ignoring.");
return null;
}
if (null == this.cannedListTypeMap) {
this.cannedListTypeMap = new HashMap<>();
}
this.cannedListTypeMap.put(cannedListType, cannedMessagesSpec.cannedMessages);
GdiSmartProto.Smart smart = GdiSmartProto.Smart.newBuilder()
.setSmsNotificationService(GdiSmsNotification.SmsNotificationService.newBuilder()
.setSmsCannedListChangedNotification(
GdiSmsNotification.SmsNotificationService.SmsCannedListChangedNotification.newBuilder().addChangedType(cannedListType)
)
).build();
return prepareProtobufRequest(smart);
}
private class ProtobufFragment {
private final byte[] fragmentBytes;
private final int totalLength;
public ProtobufFragment(byte[] fragmentBytes) {
this.fragmentBytes = fragmentBytes;
this.totalLength = fragmentBytes.length;
}
public ProtobufFragment(ProtobufMessage message) {
if (message.getDataOffset() != 0)
throw new IllegalArgumentException("Cannot create fragment if message is not the first of the sequence");
this.fragmentBytes = message.getMessageBytes();
this.totalLength = message.getTotalProtobufLength();
}
public ProtobufFragment(ProtobufFragment existing, ProtobufMessage toMerge) {
if (toMerge.getDataOffset() != existing.fragmentBytes.length)
throw new IllegalArgumentException("Cannot merge fragment: incoming message has different offset than needed");
this.fragmentBytes = ArrayUtils.addAll(existing.fragmentBytes, toMerge.getMessageBytes());
this.totalLength = existing.totalLength;
}
public ProtobufMessage getNextChunk(ProtobufStatusMessage protobufStatusMessage) {
int start = protobufStatusMessage.getDataOffset() + maxChunkSize;
int length = Math.min(maxChunkSize, this.fragmentBytes.length - start);
return new ProtobufMessage(protobufStatusMessage.getMessageType(),
protobufStatusMessage.getRequestId(),
start,
this.totalLength,
length,
ArrayUtils.subarray(this.fragmentBytes, start, start + length));
}
public boolean isComplete() {
return totalLength == fragmentBytes.length;
}
}
}

View File

@ -0,0 +1,134 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator;
import java.nio.ByteBuffer;
public class CobsCoDec {
private static final long BUFFER_TIMEOUT = 1500L; // turn this value up while debugging
private final ByteBuffer byteBuffer = ByteBuffer.allocate(10_000);
private long lastUpdate;
private byte[] cobsDecodedMessage;
/**
* Accumulates received bytes in a local buffer, clearing it after a timeout, and attempts to
* parse it.
*
* @param bytes
*/
public void receivedBytes(byte[] bytes) {
final long now = System.currentTimeMillis();
if ((now - lastUpdate) > BUFFER_TIMEOUT) {
reset();
}
lastUpdate = now;
byteBuffer.put(bytes);
decode();
}
private void reset() {
cobsDecodedMessage = null;
byteBuffer.clear();
}
public byte[] retrieveMessage() {
final byte[] resultPacket = cobsDecodedMessage;
cobsDecodedMessage = null;
return resultPacket;
}
/**
* COBS decoding algorithm variant, which relies on a leading and a trailing 0 byte (the former
* is not part of default implementations).
* This function removes the complete message from the internal buffer, if it could be decoded.
*/
private void decode() {
if (cobsDecodedMessage != null) {
// packet is waiting, unable to parse more
return;
}
if (byteBuffer.position() < 4) {
// minimal payload length including the padding
return;
}
if (0 != byteBuffer.get(byteBuffer.position() - 1))
return; //no 0x00 at the end, hence no full packet
byteBuffer.position(byteBuffer.position() - 1); //don't process the trailing 0
byteBuffer.flip();
if (0 != byteBuffer.get())
return; //no 0x00 at the start
ByteBuffer decodedBytesBuffer = ByteBuffer.allocate(byteBuffer.limit()); //leading and trailing 0x00 bytes
while (byteBuffer.hasRemaining()) {
byte code = byteBuffer.get();
if (code == 0) {
break;
}
int codeValue = code & 0xFF;
int payloadSize = codeValue - 1;
for (int i = 0; i < payloadSize; i++) {
decodedBytesBuffer.put(byteBuffer.get());
}
if (codeValue != 0xFF && byteBuffer.hasRemaining()) {
decodedBytesBuffer.put((byte) 0); // Append a zero byte after the payload
}
}
decodedBytesBuffer.flip();
cobsDecodedMessage = new byte[decodedBytesBuffer.remaining()];
decodedBytesBuffer.get(cobsDecodedMessage);
byteBuffer.compact();
}
// this implementation of COBS relies on a leading and a trailing 0 byte (the former is not part of default implementations)
public byte[] encode(byte[] data) {
ByteBuffer encodedBytesBuffer = ByteBuffer.allocate((data.length * 2) + 1); // Maximum expansion
encodedBytesBuffer.put((byte) 0);// Garmin initial padding
ByteBuffer buffer = ByteBuffer.wrap(data);
while (buffer.position() < buffer.limit()) {
int startPos = buffer.position();
int zeroIndex = buffer.position();
while (buffer.hasRemaining() && buffer.get() != 0) {
zeroIndex++;
}
int payloadSize = zeroIndex - startPos;
while (payloadSize > 0xFE) {
encodedBytesBuffer.put((byte) 0xFF); // Maximum payload size indicator
for (int i = 0; i < 0xFE; i++) {
encodedBytesBuffer.put(data[startPos + i]);
}
payloadSize -= 0xFE;
startPos += 0xFE;
}
encodedBytesBuffer.put((byte) (payloadSize + 1));
for (int i = startPos; i < zeroIndex; i++) {
encodedBytesBuffer.put(data[i]);
}
if (buffer.hasRemaining()) {
zeroIndex++; // Include the zero byte in the next block
}
if (!buffer.hasRemaining() && payloadSize == 0) {
break;
}
buffer.position(zeroIndex);
}
encodedBytesBuffer.put((byte) 0); // Append a zero byte to indicate end of encoding
encodedBytesBuffer.flip();
byte[] encodedBytes = new byte[encodedBytesBuffer.remaining()];
encodedBytesBuffer.get(encodedBytes);
return encodedBytes;
}
}

View File

@ -0,0 +1,20 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCharacteristic;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
public interface ICommunicator {
void sendMessage(byte[] message);
void onMtuChanged(final int mtu);
void initializeDevice(TransactionBuilder builder);
boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic);
interface Callback {
void onMessage(byte[] message);
}
}

View File

@ -0,0 +1,41 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.v1;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCharacteristic;
import java.util.UUID;
import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.ICommunicator;
public class CommunicatorV1 implements ICommunicator {
public static final UUID UUID_SERVICE_GARMIN_GFDI = VivomoveConstants.UUID_SERVICE_GARMIN_GFDI;
private final GarminSupport mSupport;
public CommunicatorV1(final GarminSupport garminSupport) {
this.mSupport = garminSupport;
}
@Override
public void onMtuChanged(final int mtu) {
}
@Override
public void initializeDevice(final TransactionBuilder builder) {
}
@Override
public void sendMessage(final byte[] message) {
}
@Override
public boolean onCharacteristicChanged(final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic) {
return false;
}
}

View File

@ -0,0 +1,177 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.v2;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCharacteristic;
import org.apache.commons.lang3.ArrayUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Arrays;
import java.util.UUID;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.CobsCoDec;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.ICommunicator;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
public class CommunicatorV2 implements ICommunicator {
public static final UUID UUID_SERVICE_GARMIN_ML_GFDI = UUID.fromString("6A4E2800-667B-11E3-949A-0800200C9A66"); //VivomoveConstants.UUID_SERVICE_GARMIN_ML_GFDI;
public static final UUID UUID_CHARACTERISTIC_GARMIN_ML_GFDI_SEND = UUID.fromString("6a4e2822-667b-11e3-949a-0800200c9a66"); //VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_ML_GFDI_SEND;
public static final UUID UUID_CHARACTERISTIC_GARMIN_ML_GFDI_RECEIVE = UUID.fromString("6a4e2812-667b-11e3-949a-0800200c9a66"); //VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_ML_GFDI_RECEIVE;
public int maxWriteSize = 20; //VivomoveConstants.MAX_WRITE_SIZE
private static final Logger LOG = LoggerFactory.getLogger(CommunicatorV2.class);
public final CobsCoDec cobsCoDec;
private final GarminSupport mSupport;
private final long gadgetBridgeClientID = 2L;
private int gfdiHandle = 0;
public CommunicatorV2(final GarminSupport garminSupport) {
this.mSupport = garminSupport;
this.cobsCoDec = new CobsCoDec();
}
@Override
public void onMtuChanged(final int mtu) {
maxWriteSize = mtu - 3;
}
@Override
public void initializeDevice(final TransactionBuilder builder) {
builder.notify(this.mSupport.getCharacteristic(UUID_CHARACTERISTIC_GARMIN_ML_GFDI_RECEIVE), true);
builder.write(this.mSupport.getCharacteristic(UUID_CHARACTERISTIC_GARMIN_ML_GFDI_SEND), closeAllServices());
}
@Override
public void sendMessage(final byte[] message) {
if (null == message)
return;
if (0 == gfdiHandle) {
LOG.error("CANNOT SENT GFDI MESSAGE, HANDLE NOT YET SET. MESSAGE {}", message);
return;
}
final byte[] payload = cobsCoDec.encode(message);
// LOG.debug("SENDING MESSAGE: {} - COBS ENCODED: {}", GB.hexdump(message), GB.hexdump(payload));
final TransactionBuilder builder = new TransactionBuilder("sendMessage()");
int remainingBytes = payload.length;
if (remainingBytes > maxWriteSize - 1) {
int position = 0;
while (remainingBytes > 0) {
final byte[] fragment = Arrays.copyOfRange(payload, position, position + Math.min(remainingBytes, maxWriteSize - 1));
builder.write(this.mSupport.getCharacteristic(UUID_CHARACTERISTIC_GARMIN_ML_GFDI_SEND), ArrayUtils.addAll(new byte[]{(byte) gfdiHandle}, fragment));
position += fragment.length;
remainingBytes -= fragment.length;
}
} else {
builder.write(this.mSupport.getCharacteristic(UUID_CHARACTERISTIC_GARMIN_ML_GFDI_SEND), ArrayUtils.addAll(new byte[]{(byte) gfdiHandle}, payload));
}
builder.queue(this.mSupport.getQueue());
}
@Override
public boolean onCharacteristicChanged(final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic) {
ByteBuffer message = ByteBuffer.wrap(characteristic.getValue()).order(ByteOrder.LITTLE_ENDIAN);
// LOG.debug("RECEIVED: {}", GB.hexdump(message.array()));
final byte handle = message.get();
if (0x00 == handle) { //handle management message
final byte type = message.get();
final long incomingClientID = message.getLong();
if (incomingClientID != this.gadgetBridgeClientID) {
LOG.debug("Ignoring incoming message, client ID is not ours. Message: {}", GB.hexdump(message.array()));
}
RequestType requestType = RequestType.fromCode(type);
if (null == requestType) {
LOG.error("Unknown request type. Message: {}", message.array());
return true;
}
switch (requestType) {
case REGISTER_ML_REQ: //register service request
case CLOSE_HANDLE_REQ: //close handle request
case CLOSE_ALL_REQ: //close all handles request
case UNK_REQ: //unknown request
LOG.warn("Received handle request, expecting responses. Message: {}", message.array());
case REGISTER_ML_RESP: //register service response
LOG.debug("Received register response. Message: {}", message.array());
final short registeredService = message.getShort();
final byte status = message.get();
if (0 == status && 1 == registeredService) { //success
this.gfdiHandle = message.get();
}
break;
case CLOSE_HANDLE_RESP: //close handle response
LOG.debug("Received close handle response. Message: {}", message.array());
break;
case CLOSE_ALL_RESP: //close all handles response
LOG.debug("Received close all handles response. Message: {}", message.array());
new TransactionBuilder("open GFDI")
.write(this.mSupport.getCharacteristic(UUID_CHARACTERISTIC_GARMIN_ML_GFDI_SEND), registerGFDI())
.queue(this.mSupport.getQueue());
break;
case UNK_RESP: //unknown response
LOG.debug("Received unknown. Message: {}", message.array());
break;
}
return true;
} else if (this.gfdiHandle == handle) {
byte[] partial = new byte[message.remaining()];
message.get(partial);
this.cobsCoDec.receivedBytes(partial);
this.mSupport.onMessage(this.cobsCoDec.retrieveMessage());
return true;
}
return false;
}
protected byte[] closeAllServices() {
ByteBuffer toSend = ByteBuffer.allocate(13);
toSend.order(ByteOrder.BIG_ENDIAN);
toSend.putShort((short) RequestType.CLOSE_ALL_REQ.ordinal()); //close all services
toSend.order(ByteOrder.LITTLE_ENDIAN);
toSend.putLong(this.gadgetBridgeClientID);
toSend.putShort((short) 0);
return toSend.array();
}
protected byte[] registerGFDI() {
ByteBuffer toSend = ByteBuffer.allocate(13);
toSend.order(ByteOrder.BIG_ENDIAN);
toSend.putShort((short) RequestType.REGISTER_ML_REQ.ordinal()); //register service request
toSend.order(ByteOrder.LITTLE_ENDIAN);
toSend.putLong(this.gadgetBridgeClientID);
toSend.putShort((short) 1); //service GFDI
return toSend.array();
}
enum RequestType {
REGISTER_ML_REQ,
REGISTER_ML_RESP,
CLOSE_HANDLE_REQ,
CLOSE_HANDLE_RESP,
UNK_HANDLE,
CLOSE_ALL_REQ,
CLOSE_ALL_RESP,
UNK_REQ,
UNK_RESP;
public static RequestType fromCode(final int code) {
for (final RequestType requestType : RequestType.values()) {
if (requestType.ordinal() == code) {
return requestType;
}
}
return null;
}
}
}

View File

@ -0,0 +1,9 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.FileTransferHandler;
public class FileDownloadedDeviceEvent extends GBDeviceEvent {
public FileTransferHandler.DirectoryEntry directoryEntry;
}

View File

@ -0,0 +1,9 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
public class NotificationSubscriptionDeviceEvent extends GBDeviceEvent {
public boolean enable;
}

View File

@ -0,0 +1,19 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.FileType;
public class SupportedFileTypesDeviceEvent extends GBDeviceEvent {
private final List<FileType> supportedFileTypes;
public SupportedFileTypesDeviceEvent(List<FileType> fileTypes) {
this.supportedFileTypes = fileTypes;
}
public List<FileType> getSupportedFileTypes() {
return supportedFileTypes;
}
}

View File

@ -0,0 +1,18 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
public class WeatherRequestDeviceEvent extends GBDeviceEvent {
private final int format;
private final int latitude;
private final int longitude;
private final int hoursOfForecast;
public WeatherRequestDeviceEvent(int format, int latitude, int longitude, int hoursOfForecast) {
this.format = format;
this.latitude = latitude;
this.longitude = longitude;
this.hoursOfForecast = hoursOfForecast;
}
}

View File

@ -0,0 +1,60 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit;
import java.nio.ByteBuffer;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminByteBufferReader;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
public class DevFieldDefinition {
public final ByteBuffer valueHolder;
private final int fieldDefinitionNumber;
private final int size;
private final int developerDataIndex;
private BaseType baseType;
private String name;
public DevFieldDefinition(int fieldDefinitionNumber, int size, int developerDataIndex, String name) {
this.fieldDefinitionNumber = fieldDefinitionNumber;
this.size = size;
this.developerDataIndex = developerDataIndex;
this.name = name;
this.valueHolder = ByteBuffer.allocate(size);
}
public static DevFieldDefinition parseIncoming(GarminByteBufferReader garminByteBufferReader) {
int number = garminByteBufferReader.readByte();
int size = garminByteBufferReader.readByte();
int developerDataIndex = garminByteBufferReader.readByte();
return new DevFieldDefinition(number, size, developerDataIndex, "");
}
public BaseType getBaseType() {
return baseType;
}
public void setBaseType(BaseType baseType) {
this.baseType = baseType;
}
public int getDeveloperDataIndex() {
return developerDataIndex;
}
public int getFieldDefinitionNumber() {
return fieldDefinitionNumber;
}
public int getSize() {
return size;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

View File

@ -0,0 +1,82 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit;
import java.nio.ByteBuffer;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminByteBufferReader;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionTimestamp;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.MessageWriter;
public class FieldDefinition implements FieldInterface {
protected final BaseType baseType;
protected final int scale;
protected final int offset;
private final int number;
private final int size;
private final String name;
public FieldDefinition(int number, int size, BaseType baseType, String name, int scale, int offset) {
this.number = number;
this.size = size;
this.baseType = baseType;
this.name = name;
this.scale = scale;
this.offset = offset;
}
public FieldDefinition(int number, int size, BaseType baseType, String name) {
this(number, size, baseType, name, 1, 0);
}
public static FieldDefinition parseIncoming(GarminByteBufferReader garminByteBufferReader, GlobalFITMessage globalFITMessage) {
int number = garminByteBufferReader.readByte();
int size = garminByteBufferReader.readByte();
int baseTypeIdentifier = garminByteBufferReader.readByte();
BaseType baseType = BaseType.fromIdentifier(baseTypeIdentifier);
if (number == 253 && size == 4 && baseType.equals(BaseType.UINT32))
return new FieldDefinitionTimestamp(number, size, baseType, "253_timestamp");
FieldDefinition global = globalFITMessage.getFieldDefinition(number, size);
if (null != global && global.getBaseType().equals(baseType)) {
return global;
}
return new FieldDefinition(number, size, baseType, "");
}
public int getNumber() {
return number;
}
public int getSize() {
return size;
}
public BaseType getBaseType() {
return baseType;
}
public String getName() {
return name;
}
public void generateOutgoingPayload(MessageWriter writer) {
writer.writeByte(number);
writer.writeByte(size);
writer.writeByte(baseType.getIdentifier());
}
@Override
public Object decode(ByteBuffer byteBuffer) {
return baseType.decode(byteBuffer, scale, offset);
}
@Override
public void encode(ByteBuffer byteBuffer, Object o) {
baseType.encode(byteBuffer, o, scale, offset);
}
@Override
public void invalidate(ByteBuffer byteBuffer) {
baseType.invalidate(byteBuffer);
}
}

View File

@ -0,0 +1,63 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionAlarm;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionDayOfWeek;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionFileType;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionGoalSource;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionGoalType;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionLanguage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionMeasurementSystem;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionTemperature;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionTimestamp;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionWeatherCondition;
public class FieldDefinitionFactory {
public static FieldDefinition create(int localNumber, int size, BaseType baseType, String name, int scale, int offset) {
return new FieldDefinition(localNumber, size, baseType, name, scale, offset);
}
public static FieldDefinition create(int localNumber, int size, FIELD field, BaseType baseType, String name, int scale, int offset) {
if (null == field) {
return new FieldDefinition(localNumber, size, baseType, name, scale, offset);
}
switch (field) {
case ALARM:
return new FieldDefinitionAlarm(localNumber, size, baseType, name);
case DAY_OF_WEEK:
return new FieldDefinitionDayOfWeek(localNumber, size, baseType, name);
case FILE_TYPE:
return new FieldDefinitionFileType(localNumber, size, baseType, name);
case GOAL_SOURCE:
return new FieldDefinitionGoalSource(localNumber, size, baseType, name);
case GOAL_TYPE:
return new FieldDefinitionGoalType(localNumber, size, baseType, name);
case MEASUREMENT_SYSTEM:
return new FieldDefinitionMeasurementSystem(localNumber, size, baseType, name);
case TEMPERATURE:
return new FieldDefinitionTemperature(localNumber, size, baseType, name);
case TIMESTAMP:
return new FieldDefinitionTimestamp(localNumber, size, baseType, name);
case WEATHER_CONDITION:
return new FieldDefinitionWeatherCondition(localNumber, size, baseType, name);
case LANGUAGE:
return new FieldDefinitionLanguage(localNumber, size, baseType, name);
default:
return new FieldDefinition(localNumber, size, baseType, name);
}
}
public enum FIELD {
ALARM,
DAY_OF_WEEK,
FILE_TYPE,
GOAL_SOURCE,
GOAL_TYPE,
MEASUREMENT_SYSTEM,
TEMPERATURE,
TIMESTAMP,
WEATHER_CONDITION,
LANGUAGE,
}
}

View File

@ -0,0 +1,12 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit;
import java.nio.ByteBuffer;
public interface FieldInterface {
Object decode(ByteBuffer byteBuffer);
void encode(ByteBuffer byteBuffer, Object o);
void invalidate(ByteBuffer byteBuffer);
}

View File

@ -0,0 +1,217 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit;
import androidx.annotation.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteOrder;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.ChecksumCalculator;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminByteBufferReader;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.MessageWriter;
public class FitFile {
protected static final Logger LOG = LoggerFactory.getLogger(FitFile.class);
private final Header header;
private final Map<RecordDefinition, List<RecordData>> dataRecords;
private final boolean canGenerateOutput;
public FitFile(Header header, Map<RecordDefinition, List<RecordData>> dataRecords) {
this.header = header;
this.dataRecords = dataRecords;
this.canGenerateOutput = false;
}
public FitFile(LinkedHashMap<RecordDefinition, List<RecordData>> dataRecords) {
this.dataRecords = dataRecords;
this.header = new Header(true, 16, 21117);
this.canGenerateOutput = true;
}
private static byte[] readFileToByteArray(File file) {
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); InputStream inputStream = new FileInputStream(file)) {
byte[] buffer = new byte[1024];
int length;
while ((length = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, length);
}
return outputStream.toByteArray();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public static FitFile parseIncoming(File file) {
return parseIncoming(readFileToByteArray(file));
}
//TODO: process file in chunks??
public static FitFile parseIncoming(byte[] fileContents) {
final GarminByteBufferReader garminByteBufferReader = new GarminByteBufferReader(fileContents);
garminByteBufferReader.setByteOrder(ByteOrder.LITTLE_ENDIAN);
final Header header = Header.parseIncomingHeader(garminByteBufferReader);
Map<RecordHeader, RecordDefinition> recordDefinitionMap = new HashMap<>(); //needed because the headers can be redefined in the file. The last header wins
Map<RecordDefinition, List<RecordData>> dataRecords = new LinkedHashMap<>();
Long referenceTimestamp = null;
while (garminByteBufferReader.getPosition() < header.getHeaderSize() + header.getDataSize()) {
byte rawRecordHeader = (byte) garminByteBufferReader.readByte();
RecordHeader recordHeader = new RecordHeader(rawRecordHeader);
if (recordHeader.isCompressedTimestamp()) {
referenceTimestamp += recordHeader.getTimeOffset();
recordHeader.setReferenceTimestamp(referenceTimestamp);
}
if (recordHeader.isDefinition()) {
final RecordDefinition recordDefinition = RecordDefinition.parseIncoming(garminByteBufferReader, recordHeader);
if (recordDefinition != null) {
if (recordHeader.isDeveloperData())
for (RecordDefinition rd : dataRecords.keySet()) {
if (GlobalFITMessage.FIELD_DESCRIPTION.equals(rd.getGlobalFITMessage()))
recordDefinition.populateDevFields(dataRecords.get(rd));
}
recordDefinitionMap.put(recordHeader, recordDefinition);
dataRecords.put(recordDefinition, new ArrayList<>());
}
} else {
final RecordDefinition referenceRecordDefinition = recordDefinitionMap.get(recordHeader);
final List<RecordData> myList = dataRecords.get(referenceRecordDefinition);
if (referenceRecordDefinition != null) {
final RecordData runningData = new RecordData(referenceRecordDefinition, recordHeader);
myList.add(runningData);
Long newTimestamp = runningData.parseDataMessage(garminByteBufferReader);
if (newTimestamp != null)
referenceTimestamp = newTimestamp;
}
}
}
garminByteBufferReader.setByteOrder(ByteOrder.LITTLE_ENDIAN);
int fileCrc = garminByteBufferReader.readShort();
if (fileCrc != ChecksumCalculator.computeCrc(fileContents, header.getHeaderSize(), fileContents.length - header.getHeaderSize() - 2)) {
throw new IllegalArgumentException("Wrong CRC for FIT file");
}
return new FitFile(header, dataRecords);
}
public List<RecordData> getRecordsByGlobalMessage(GlobalFITMessage globalFITMessage) {
final List<RecordData> filtered = new ArrayList<>();
for (RecordDefinition rd : dataRecords.keySet()) {
if (globalFITMessage.equals(rd.getGlobalFITMessage()))
filtered.addAll(dataRecords.get(rd));
}
return filtered;
}
public void generateOutgoingDataPayload(MessageWriter writer) {
if (!canGenerateOutput)
throw new IllegalArgumentException("Generation of previously parsed FIT file not supported.");
MessageWriter temporary = new MessageWriter();
temporary.setByteOrder(ByteOrder.LITTLE_ENDIAN);
for (Map.Entry<RecordDefinition, List<RecordData>> entry : dataRecords.entrySet()) {
RecordDefinition key = entry.getKey();
List<RecordData> valueList = entry.getValue();
key.generateOutgoingPayload(temporary);
for (RecordData rd :
valueList) {
rd.generateOutgoingDataPayload(temporary);
}
}
this.header.setDataSize(temporary.getSize());
this.header.generateOutgoingDataPayload(writer);
writer.writeBytes(temporary.getBytes());
writer.writeShort(ChecksumCalculator.computeCrc(writer.getBytes(), this.header.getHeaderSize(), writer.getBytes().length - this.header.getHeaderSize()));
}
@NonNull
@Override
public String toString() {
return dataRecords.toString();
}
static class Header {
public static final int MAGIC = 0x5449462E;
private final int headerSize;
private final int protocolVersion;
private final int profileVersion;
private final boolean hasCRC;
private int dataSize;
public Header(boolean hasCRC, int protocolVersion, int profileVersion) {
this(hasCRC, protocolVersion, profileVersion, 0);
}
public Header(boolean hasCRC, int protocolVersion, int profileVersion, int dataSize) {
this.hasCRC = hasCRC;
headerSize = hasCRC ? 14 : 12;
this.protocolVersion = protocolVersion;
this.profileVersion = profileVersion;
this.dataSize = dataSize;
}
static Header parseIncomingHeader(GarminByteBufferReader garminByteBufferReader) {
int headerSize = garminByteBufferReader.readByte();
if (headerSize < 12) {
throw new IllegalArgumentException("Too short header in FIT file.");
}
boolean hasCRC = headerSize == 14;
int protocolVersion = garminByteBufferReader.readByte();
int profileVersion = garminByteBufferReader.readShort();
int dataSize = garminByteBufferReader.readInt();
int magic = garminByteBufferReader.readInt();
if (magic != MAGIC) {
throw new IllegalArgumentException("Wrong magic header in FIT file");
}
if (hasCRC) {
int incomingCrc = garminByteBufferReader.readShort();
if (incomingCrc != ChecksumCalculator.computeCrc(garminByteBufferReader.asReadOnlyBuffer(), 0, headerSize - 2)) {
throw new IllegalArgumentException("Wrong CRC for header in FIT file");
}
// LOG.info("Fit File Header didn't have CRC, no check performed.");
}
return new Header(hasCRC, protocolVersion, profileVersion, dataSize);
}
public int getHeaderSize() {
return headerSize;
}
public int getDataSize() {
return dataSize;
}
public void setDataSize(int dataSize) {
this.dataSize = dataSize;
}
public void generateOutgoingDataPayload(MessageWriter writer) {
writer.setByteOrder(ByteOrder.LITTLE_ENDIAN);
writer.writeByte(headerSize);
writer.writeByte(protocolVersion);
writer.writeShort(profileVersion);
writer.writeInt(dataSize);
writer.writeInt(MAGIC);//magic
if (hasCRC)
writer.writeShort(ChecksumCalculator.computeCrc(writer.getBytes(), 0, writer.getBytes().length));
}
}
}

View File

@ -0,0 +1,256 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit;
import androidx.annotation.Nullable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
public class GlobalFITMessage {
public static GlobalFITMessage FILE_ID = new GlobalFITMessage(0, "FILE_ID", Arrays.asList(
new FieldDefinitionPrimitive(0, BaseType.ENUM, "type", FieldDefinitionFactory.FIELD.FILE_TYPE),
new FieldDefinitionPrimitive(1, BaseType.UINT16, "manufacturer"),
new FieldDefinitionPrimitive(2, BaseType.UINT16, "product"),
new FieldDefinitionPrimitive(3, BaseType.UINT32Z, "serial_number"),
new FieldDefinitionPrimitive(4, BaseType.UINT32, "time_created", FieldDefinitionFactory.FIELD.TIMESTAMP),
new FieldDefinitionPrimitive(5, BaseType.UINT16, "number"),
new FieldDefinitionPrimitive(6, BaseType.UINT16, "manufacturer_partner"),
new FieldDefinitionPrimitive(8, BaseType.STRING, 20, "product_name")
));
public static GlobalFITMessage DEVICE_SETTINGS = new GlobalFITMessage(2, "DEVICE_SETTINGS", Arrays.asList(
new FieldDefinitionPrimitive(0, BaseType.UINT8, "active_time_zone"),
new FieldDefinitionPrimitive(1, BaseType.UINT32, "utc_offset"),
new FieldDefinitionPrimitive(2, BaseType.UINT32, "time_offset"),
new FieldDefinitionPrimitive(4, BaseType.ENUM, "time_mode"),
new FieldDefinitionPrimitive(5, BaseType.SINT8, "time_zone_offset"),
new FieldDefinitionPrimitive(12, BaseType.ENUM, "backlight_mode"),
new FieldDefinitionPrimitive(36, BaseType.ENUM, "activity_tracker_enabled"),
new FieldDefinitionPrimitive(46, BaseType.ENUM, "move_alert_enabled"),
new FieldDefinitionPrimitive(47, BaseType.ENUM, "date_mode"),
new FieldDefinitionPrimitive(55, BaseType.ENUM, "display_orientation"),
new FieldDefinitionPrimitive(56, BaseType.ENUM, "mounting_side"),
new FieldDefinitionPrimitive(57, BaseType.UINT16, "default_page"),
new FieldDefinitionPrimitive(58, BaseType.UINT16, "autosync_min_steps"),
new FieldDefinitionPrimitive(59, BaseType.UINT16, "autosync_min_time"),
new FieldDefinitionPrimitive(86, BaseType.ENUM, "ble_auto_upload_enabled"),
new FieldDefinitionPrimitive(90, BaseType.UINT32, "auto_activity_detect")
));
public static GlobalFITMessage USER_PROFILE = new GlobalFITMessage(3, "USER_PROFILE", Arrays.asList(
new FieldDefinitionPrimitive(0, BaseType.STRING, 8, "friendly_name"),
new FieldDefinitionPrimitive(1, BaseType.ENUM, "gender"),
new FieldDefinitionPrimitive(2, BaseType.UINT8, "age"),
new FieldDefinitionPrimitive(3, BaseType.UINT8, "height"),
new FieldDefinitionPrimitive(4, BaseType.UINT16, "weight", 10, 0),
new FieldDefinitionPrimitive(5, BaseType.ENUM, "language", FieldDefinitionFactory.FIELD.LANGUAGE),
new FieldDefinitionPrimitive(6, BaseType.ENUM, "elev_setting", FieldDefinitionFactory.FIELD.MEASUREMENT_SYSTEM),
new FieldDefinitionPrimitive(7, BaseType.ENUM, "weight_setting", FieldDefinitionFactory.FIELD.MEASUREMENT_SYSTEM),
new FieldDefinitionPrimitive(8, BaseType.UINT8, "resting_heart_rate"),
new FieldDefinitionPrimitive(10, BaseType.UINT8, "default_max_biking_heart_rate"),
new FieldDefinitionPrimitive(11, BaseType.UINT8, "default_max_heart_rate"),
new FieldDefinitionPrimitive(12, BaseType.ENUM, "hr_setting"),
new FieldDefinitionPrimitive(13, BaseType.ENUM, "speed_setting", FieldDefinitionFactory.FIELD.MEASUREMENT_SYSTEM),
new FieldDefinitionPrimitive(14, BaseType.ENUM, "dist_setting", FieldDefinitionFactory.FIELD.MEASUREMENT_SYSTEM),
new FieldDefinitionPrimitive(16, BaseType.ENUM, "power_setting"),
new FieldDefinitionPrimitive(17, BaseType.ENUM, "activity_class"),
new FieldDefinitionPrimitive(18, BaseType.ENUM, "position_setting"),
new FieldDefinitionPrimitive(21, BaseType.ENUM, "temperature_setting", FieldDefinitionFactory.FIELD.MEASUREMENT_SYSTEM),
new FieldDefinitionPrimitive(28, BaseType.UINT32, "wake_time"),
new FieldDefinitionPrimitive(29, BaseType.UINT32, "sleep_time"),
new FieldDefinitionPrimitive(30, BaseType.ENUM, "height_setting", FieldDefinitionFactory.FIELD.MEASUREMENT_SYSTEM),
new FieldDefinitionPrimitive(31, BaseType.UINT16, "user_running_step_length"),
new FieldDefinitionPrimitive(32, BaseType.UINT16, "user_walking_step_length")
));
public static GlobalFITMessage ZONES_TARGET = new GlobalFITMessage(7, "ZONES_TARGET", Arrays.asList(
new FieldDefinitionPrimitive(3, BaseType.UINT16, "functional_threshold_power"),
new FieldDefinitionPrimitive(1, BaseType.UINT8, "max_heart_rate"),
new FieldDefinitionPrimitive(2, BaseType.UINT8, "threshold_heart_rate"),
new FieldDefinitionPrimitive(5, BaseType.ENUM, "hr_calc_type"), //1=percent_max_hr
new FieldDefinitionPrimitive(7, BaseType.ENUM, "pwr_calc_type") //1=percent_ftp
));
public static GlobalFITMessage SPORT = new GlobalFITMessage(12, "SPORT", Arrays.asList(
new FieldDefinitionPrimitive(0, BaseType.ENUM, "sport"),
new FieldDefinitionPrimitive(1, BaseType.ENUM, "sub_sport"),
new FieldDefinitionPrimitive(3, BaseType.STRING, 24, "name")
));
public static GlobalFITMessage GOALS = new GlobalFITMessage(15, "GOALS", Arrays.asList(
new FieldDefinitionPrimitive(4, BaseType.ENUM, "type", FieldDefinitionFactory.FIELD.GOAL_TYPE),
new FieldDefinitionPrimitive(7, BaseType.UINT32, "target_value"),
new FieldDefinitionPrimitive(11, BaseType.ENUM, "source", FieldDefinitionFactory.FIELD.GOAL_SOURCE)
));
public static GlobalFITMessage RECORD = new GlobalFITMessage(20, "RECORD", Arrays.asList(
new FieldDefinitionPrimitive(3, BaseType.UINT8, "heart_rate"),
new FieldDefinitionPrimitive(253, BaseType.UINT32, "timestamp", FieldDefinitionFactory.FIELD.TIMESTAMP)
));
public static GlobalFITMessage FILE_CREATOR = new GlobalFITMessage(49, "FILE_CREATOR", Arrays.asList(
new FieldDefinitionPrimitive(0, BaseType.UINT16, "software_version"),
new FieldDefinitionPrimitive(1, BaseType.UINT8, "hardware_version")
));
public static GlobalFITMessage CONNECTIVITY = new GlobalFITMessage(127, "CONNECTIVITY", Arrays.asList(
new FieldDefinitionPrimitive(0, BaseType.ENUM, "bluetooth_enabled"),
new FieldDefinitionPrimitive(3, BaseType.STRING, 20, "name"),
new FieldDefinitionPrimitive(4, BaseType.ENUM, "live_tracking_enabled"),
new FieldDefinitionPrimitive(5, BaseType.ENUM, "weather_conditions_enabled"),
new FieldDefinitionPrimitive(6, BaseType.ENUM, "weather_alerts_enabled"),
new FieldDefinitionPrimitive(7, BaseType.ENUM, "auto_activity_upload_enabled"),
new FieldDefinitionPrimitive(8, BaseType.ENUM, "course_download_enabled"),
new FieldDefinitionPrimitive(9, BaseType.ENUM, "workout_download_enabled"),
new FieldDefinitionPrimitive(10, BaseType.ENUM, "gps_ephemeris_download_enabled")
));
public static GlobalFITMessage WEATHER = new GlobalFITMessage(128, "WEATHER", Arrays.asList(
new FieldDefinitionPrimitive(0, BaseType.ENUM, "weather_report"),
new FieldDefinitionPrimitive(1, BaseType.SINT8, "temperature", FieldDefinitionFactory.FIELD.TEMPERATURE),
new FieldDefinitionPrimitive(2, BaseType.ENUM, "condition", FieldDefinitionFactory.FIELD.WEATHER_CONDITION),
new FieldDefinitionPrimitive(3, BaseType.UINT16, "wind_direction"),
new FieldDefinitionPrimitive(4, BaseType.UINT16, "wind_speed", 298, 0),
new FieldDefinitionPrimitive(5, BaseType.UINT8, "precipitation_probability"),
new FieldDefinitionPrimitive(6, BaseType.SINT8, "temperature_feels_like", FieldDefinitionFactory.FIELD.TEMPERATURE),
new FieldDefinitionPrimitive(7, BaseType.UINT8, "relative_humidity"),
new FieldDefinitionPrimitive(8, BaseType.STRING, 15, "location"),
new FieldDefinitionPrimitive(9, BaseType.UINT32, "observed_at_time", FieldDefinitionFactory.FIELD.TIMESTAMP),
new FieldDefinitionPrimitive(10, BaseType.SINT32, "observed_location_lat"),
new FieldDefinitionPrimitive(11, BaseType.SINT32, "observed_location_long"),
new FieldDefinitionPrimitive(12, BaseType.ENUM, "day_of_week", FieldDefinitionFactory.FIELD.DAY_OF_WEEK),
new FieldDefinitionPrimitive(13, BaseType.SINT8, "high_temperature", FieldDefinitionFactory.FIELD.TEMPERATURE),
new FieldDefinitionPrimitive(14, BaseType.SINT8, "low_temperature", FieldDefinitionFactory.FIELD.TEMPERATURE),
new FieldDefinitionPrimitive(15, BaseType.SINT8, "dew_point"),
new FieldDefinitionPrimitive(16, BaseType.FLOAT32, "uv_index"),
new FieldDefinitionPrimitive(17, BaseType.ENUM, "air_quality"),
new FieldDefinitionPrimitive(253, BaseType.UINT32, "timestamp", FieldDefinitionFactory.FIELD.TIMESTAMP)
));
public static GlobalFITMessage WATCHFACE_SETTINGS = new GlobalFITMessage(159, "WATCHFACE_SETTINGS", Arrays.asList(
new FieldDefinitionPrimitive(0, BaseType.ENUM, "mode"), //1=analog
new FieldDefinitionPrimitive(1, BaseType.BASE_TYPE_BYTE, "layout")
));
public static GlobalFITMessage FIELD_DESCRIPTION = new GlobalFITMessage(206, "FIELD_DESCRIPTION", Arrays.asList(
new FieldDefinitionPrimitive(0, BaseType.UINT8, "developer_data_index"),
new FieldDefinitionPrimitive(1, BaseType.UINT8, "field_definition_number"),
new FieldDefinitionPrimitive(2, BaseType.UINT8, "fit_base_type_id"),
new FieldDefinitionPrimitive(3, BaseType.STRING, 64, "field_name"),
new FieldDefinitionPrimitive(8, BaseType.STRING, 16, "units")
));
public static GlobalFITMessage DEVELOPER_DATA = new GlobalFITMessage(207, "DEVELOPER_DATA", Arrays.asList(
new FieldDefinitionPrimitive(1, BaseType.BASE_TYPE_BYTE, 16, "application_id"),
new FieldDefinitionPrimitive(3, BaseType.UINT8, "developer_data_index")
));
// UNK_216(216, null), //activity
public static GlobalFITMessage ALARM_SETTINGS = new GlobalFITMessage(222, "ALARM_SETTINGS", Arrays.asList(
new FieldDefinitionPrimitive(0, BaseType.UINT16, "time", FieldDefinitionFactory.FIELD.ALARM)
));
public static Map<Integer, GlobalFITMessage> KNOWNMESSAGES = new HashMap<Integer, GlobalFITMessage>() {{
put(0, FILE_ID);
put(2, DEVICE_SETTINGS);
put(3, USER_PROFILE);
put(7, ZONES_TARGET);
put(12, SPORT);
put(15, GOALS);
put(20, RECORD);
put(49, FILE_CREATOR);
put(127, CONNECTIVITY);
put(128, WEATHER);
put(159, WATCHFACE_SETTINGS);
put(206, FIELD_DESCRIPTION);
put(207, DEVELOPER_DATA);
put(222, ALARM_SETTINGS);
}};
private final int number;
private final String name;
private final List<FieldDefinitionPrimitive> fieldDefinitionPrimitives;
GlobalFITMessage(int number, String name, List<FieldDefinitionPrimitive> fieldDefinitionPrimitives) {
this.number = number;
this.name = name;
this.fieldDefinitionPrimitives = fieldDefinitionPrimitives;
}
public static GlobalFITMessage fromNumber(final int number) {
final GlobalFITMessage found = KNOWNMESSAGES.get(number);
if (found != null) {
return found;
}
return new GlobalFITMessage(number, "UNK_" + number, null);
}
public String name() {
return this.name;
}
public int getNumber() {
return number;
}
@Nullable
public List<FieldDefinition> getFieldDefinitions(int... ids) {
if (null == fieldDefinitionPrimitives)
return null;
List<FieldDefinition> subset = new ArrayList<>(ids.length);
for (int id :
ids) {
for (FieldDefinitionPrimitive fieldDefinitionPrimitive :
fieldDefinitionPrimitives) {
if (fieldDefinitionPrimitive.number == id) {
subset.add(FieldDefinitionFactory.create(fieldDefinitionPrimitive.number, fieldDefinitionPrimitive.size, fieldDefinitionPrimitive.type, fieldDefinitionPrimitive.baseType, fieldDefinitionPrimitive.name, fieldDefinitionPrimitive.scale, fieldDefinitionPrimitive.offset));
}
}
}
return subset;
}
@Nullable
public FieldDefinition getFieldDefinition(int id, int size) {
if (null == fieldDefinitionPrimitives)
return null;
for (GlobalFITMessage.FieldDefinitionPrimitive fieldDefinitionPrimitive :
fieldDefinitionPrimitives) {
if (fieldDefinitionPrimitive.number == id) {
return FieldDefinitionFactory.create(fieldDefinitionPrimitive.number, size, fieldDefinitionPrimitive.type, fieldDefinitionPrimitive.baseType, fieldDefinitionPrimitive.name, fieldDefinitionPrimitive.scale, fieldDefinitionPrimitive.offset);
}
}
return null;
}
static class FieldDefinitionPrimitive {
private final int number;
private final BaseType baseType;
private final String name;
private final FieldDefinitionFactory.FIELD type;
private final int scale;
private final int offset;
private final int size;
public FieldDefinitionPrimitive(int number, BaseType baseType, int size, String name, FieldDefinitionFactory.FIELD type, int scale, int offset) {
this.number = number;
this.baseType = baseType;
this.size = size;
this.name = name;
this.type = type;
this.scale = scale;
this.offset = offset;
}
public FieldDefinitionPrimitive(int number, BaseType baseType, String name, FieldDefinitionFactory.FIELD type) {
this(number, baseType, baseType.getSize(), name, type, 1, 0);
}
public FieldDefinitionPrimitive(int number, BaseType baseType, String name) {
this(number, baseType, baseType.getSize(), name, null, 1, 0);
}
public FieldDefinitionPrimitive(int number, BaseType baseType, int size, String name) {
this(number, baseType, size, name, null, 1, 0);
}
public FieldDefinitionPrimitive(int number, BaseType baseType, String name, int scale, int offset) {
this(number, baseType, baseType.getSize(), name, null, scale, offset);
}
}
}

View File

@ -0,0 +1,54 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit;
import androidx.annotation.Nullable;
import java.nio.ByteOrder;
import java.util.List;
public enum PredefinedLocalMessage {
TODAY_WEATHER_CONDITIONS(6, GlobalFITMessage.WEATHER,
new int[]{0, 253, 9, 1, 14, 13, 2, 3, 5, 4, 6, 7, 10, 11, 8}
),
HOURLY_WEATHER_FORECAST(9, GlobalFITMessage.WEATHER,
new int[]{0, 253, 1, 2, 3, 4, 5, 7, 15, 16, 17}
),
DAILY_WEATHER_FORECAST(10, GlobalFITMessage.WEATHER,
new int[]{0, 253, 14, 13, 2, 5, 12}
);
private final int type;
private final GlobalFITMessage globalFITMessage;
private final int[] globalDefinitionIds;
PredefinedLocalMessage(int type, GlobalFITMessage globalFITMessage, int[] globalDefinitionIds) {
this.type = type;
this.globalFITMessage = globalFITMessage;
this.globalDefinitionIds = globalDefinitionIds;
}
@Nullable
public static PredefinedLocalMessage fromType(int type) {
for (final PredefinedLocalMessage predefinedLocalMessage : PredefinedLocalMessage.values()) {
if (predefinedLocalMessage.getType() == type) {
return predefinedLocalMessage;
}
}
return null;
}
public List<FieldDefinition> getLocalFieldDefinitions() {
return globalFITMessage.getFieldDefinitions(globalDefinitionIds);
}
public RecordDefinition getRecordDefinition() {
return new RecordDefinition(ByteOrder.BIG_ENDIAN, this);
}
public int getType() {
return type;
}
public GlobalFITMessage getGlobalFITMessage() {
return globalFITMessage;
}
}

View File

@ -0,0 +1,279 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit;
import androidx.annotation.NonNull;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminByteBufferReader;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionTimestamp;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.MessageWriter;
import nodomain.freeyourgadget.gadgetbridge.util.ArrayUtils;
import static nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType.STRING;
public class RecordData {
private final RecordHeader recordHeader;
private final GlobalFITMessage globalFITMessage;
private final List<FieldData> fieldDataList;
protected ByteBuffer valueHolder;
public RecordData(RecordDefinition recordDefinition, RecordHeader recordHeader) {
if (null == recordDefinition.getFieldDefinitions())
throw new IllegalArgumentException("Cannot create record data without FieldDefinitions " + recordDefinition);
fieldDataList = new ArrayList<>();
this.recordHeader = recordHeader;
this.globalFITMessage = recordDefinition.getGlobalFITMessage();
int totalSize = 0;
for (FieldDefinition fieldDef :
recordDefinition.getFieldDefinitions()) {
fieldDataList.add(new FieldData(fieldDef, totalSize));
totalSize += fieldDef.getSize();
}
if (recordDefinition.getDevFieldDefinitions() != null) {
for (DevFieldDefinition fieldDef :
recordDefinition.getDevFieldDefinitions()) {
FieldDefinition temp = new FieldDefinition(fieldDef.getFieldDefinitionNumber(), fieldDef.getSize(), fieldDef.getBaseType(), fieldDef.getName());
fieldDataList.add(new FieldData(temp, totalSize));
totalSize += fieldDef.getSize();
}
}
this.valueHolder = ByteBuffer.allocate(totalSize);
valueHolder.order(recordDefinition.getByteOrder());
for (FieldData fieldData :
fieldDataList) {
fieldData.invalidate();
}
}
public RecordData(RecordDefinition recordDefinition) {
this(recordDefinition, recordDefinition.getRecordHeader());
}
public GlobalFITMessage getGlobalFITMessage() {
return globalFITMessage;
}
public Long parseDataMessage(GarminByteBufferReader garminByteBufferReader) {
garminByteBufferReader.setByteOrder(valueHolder.order());
Long referenceTimestamp = null;
for (FieldData fieldData : fieldDataList) {
Long runningTimestamp = fieldData.parseDataMessage(garminByteBufferReader);
if (runningTimestamp != null)
referenceTimestamp = runningTimestamp;
}
return referenceTimestamp;
}
public void generateOutgoingDataPayload(MessageWriter writer) {
writer.writeByte(recordHeader.generateOutgoingDataPayload());
writer.writeBytes(valueHolder.array());
}
public void setFieldByNumber(int number, Object... value) {
boolean found = false;
for (FieldData fieldData :
fieldDataList) {
if (fieldData.getNumber() == number) {
fieldData.encode(value);
found = true;
break;
}
}
if (!found) {
throw new IllegalArgumentException("Unknown field number " + number);
}
}
public void setFieldByName(String name, Object... value) {
boolean found = false;
for (FieldData fieldData :
fieldDataList) {
if (fieldData.getName().equals(name)) {
fieldData.encode(value);
found = true;
break;
}
}
if (!found) {
throw new IllegalArgumentException("Unknown field name " + name);
}
}
public Object getFieldByNumber(int number) {
for (FieldData fieldData :
fieldDataList) {
if (fieldData.getNumber() == number) {
return fieldData.decode();
}
}
throw new IllegalArgumentException("Unknown field number " + number);
}
public Object getFieldByName(String name) {
for (FieldData fieldData :
fieldDataList) {
if (fieldData.getName().equals(name)) {
return fieldData.decode();
}
}
throw new IllegalArgumentException("Unknown field name " + name);
}
public int[] getFieldsNumbers() {
int[] arr = new int[fieldDataList.size()];
int count = 0;
for (FieldData fieldData : fieldDataList) {
int number = fieldData.getNumber();
arr[count++] = number;
}
return arr;
}
public Long getComputedTimestamp() {
for (FieldData fieldData : fieldDataList) {
if (fieldData.getNumber() == 253 || fieldData.fieldDefinition instanceof FieldDefinitionTimestamp)
return (long) fieldData.decode();
}
if (recordHeader.isCompressedTimestamp())
return (long) recordHeader.getResultingTimestamp();
return null;
}
@NonNull
public String toString() {
StringBuilder oBuilder = new StringBuilder();
oBuilder.append(System.lineSeparator());
for (FieldData fieldData :
fieldDataList) {
if (fieldData.getName() != null && !fieldData.getName().equals("")) {
oBuilder.append(fieldData.getName());
} else {
oBuilder.append("unknown_" + fieldData.getNumber());
}
oBuilder.append(fieldData);
oBuilder.append(": ");
Object o = fieldData.decode();
if (o instanceof Object[]) {
oBuilder.append("[");
oBuilder.append(org.apache.commons.lang3.StringUtils.join((Object[]) o, ","));
oBuilder.append("]");
} else {
oBuilder.append(o);
}
oBuilder.append(" ");
}
if (recordHeader.isCompressedTimestamp())
oBuilder.append("compressed_timestamp: " + getComputedTimestamp());
return oBuilder.toString();
}
public PredefinedLocalMessage getPredefinedLocalMessage() {
return recordHeader.getPredefinedLocalMessage();
}
private class FieldData {
private final FieldDefinition fieldDefinition;
private final int position;
private final int size;
private final int baseSize;
public FieldData(FieldDefinition fieldDefinition, int position) {
this.fieldDefinition = fieldDefinition;
this.position = position;
this.size = fieldDefinition.getSize();
this.baseSize = fieldDefinition.getBaseType().getSize();
}
private String getName() {
return fieldDefinition.getName();
}
private int getNumber() {
return fieldDefinition.getNumber();
}
private void invalidate() {
goToPosition();
if (STRING.equals(fieldDefinition.getBaseType())) {
for (int i = 0; i < size; i++) {
valueHolder.put((byte) 0);
}
return;
}
for (int i = 0; i < (size / baseSize); i++) {
fieldDefinition.invalidate(valueHolder);
}
}
private void goToPosition() {
valueHolder.position(position);
}
private Long parseDataMessage(GarminByteBufferReader garminByteBufferReader) {
goToPosition();
valueHolder.put(garminByteBufferReader.readBytes(size));
if (fieldDefinition instanceof FieldDefinitionTimestamp)
return (Long) decode();
return null;
}
private void encode(Object... objects) {
if (objects[0] instanceof boolean[] || objects[0] instanceof short[] || objects[0] instanceof int[] || objects[0] instanceof long[] || objects[0] instanceof float[] || objects[0] instanceof double[]) {
throw new IllegalArgumentException("Array of primitive types not supported, box them to objects");
}
goToPosition();
final int slots = size / baseSize;
int i = 0;
for (Object o : objects) {
if (i++ >= slots) {
throw new IllegalArgumentException("Number of elements in array was too big for the field");
}
if (STRING.equals(fieldDefinition.getBaseType())) {
final byte[] bytes = ((String) o).getBytes(StandardCharsets.UTF_8);
valueHolder.put(Arrays.copyOf(bytes, Math.min(this.size - 1, bytes.length)));
valueHolder.put((byte) 0);
return;
}
fieldDefinition.encode(valueHolder, o);
}
}
private Object decode() {
goToPosition();
if (STRING.equals(fieldDefinition.getBaseType())) {
final byte[] bytes = new byte[size];
valueHolder.get(bytes);
final int zero = ArrayUtils.indexOf((byte) 0, bytes);
if (zero < 0) {
return new String(bytes, StandardCharsets.UTF_8);
}
return new String(bytes, 0, zero, StandardCharsets.UTF_8);
}
if (size > baseSize) {
Object[] arr = new Object[size / baseSize];
for (int i = 0; i < arr.length; i++) {
arr[i] = fieldDefinition.decode(valueHolder);
}
return arr;
}
return fieldDefinition.decode(valueHolder);
}
public String toString() {
return "(" + fieldDefinition.getBaseType().name() + "/" + size + ")";
}
}
}

View File

@ -0,0 +1,145 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.nio.ByteOrder;
import java.util.ArrayList;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminByteBufferReader;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.MessageWriter;
public class RecordDefinition {
private final RecordHeader recordHeader;
private final GlobalFITMessage globalFITMessage;
private final PredefinedLocalMessage predefinedLocalMessage;
private final java.nio.ByteOrder byteOrder;
private List<FieldDefinition> fieldDefinitions;
private List<DevFieldDefinition> devFieldDefinitions;
public RecordDefinition(RecordHeader recordHeader, ByteOrder byteOrder, PredefinedLocalMessage predefinedLocalMessage, GlobalFITMessage globalFITMessage, List<FieldDefinition> fieldDefinitions, List<DevFieldDefinition> devFieldDefinitions) {
this.recordHeader = recordHeader;
this.byteOrder = byteOrder;
this.predefinedLocalMessage = predefinedLocalMessage;
this.globalFITMessage = globalFITMessage;
this.fieldDefinitions = fieldDefinitions;
this.devFieldDefinitions = devFieldDefinitions;
}
public RecordDefinition(ByteOrder byteOrder, PredefinedLocalMessage predefinedLocalMessage) {
this(new RecordHeader(true, false, predefinedLocalMessage, null), byteOrder, predefinedLocalMessage, predefinedLocalMessage.getGlobalFITMessage(), predefinedLocalMessage.getLocalFieldDefinitions(), null);
}
public RecordDefinition(ByteOrder byteOrder, RecordHeader recordHeader, GlobalFITMessage globalFITMessage, List<FieldDefinition> fieldDefinitions) {
this(recordHeader, byteOrder, null, globalFITMessage, fieldDefinitions, null);
}
public static RecordDefinition parseIncoming(GarminByteBufferReader garminByteBufferReader, RecordHeader recordHeader) {
if (!recordHeader.isDefinition())
return null;
garminByteBufferReader.readByte();//ignore
ByteOrder byteOrder = garminByteBufferReader.readByte() == 0x01 ? ByteOrder.BIG_ENDIAN : ByteOrder.LITTLE_ENDIAN;
garminByteBufferReader.setByteOrder(byteOrder);
final int globalMesgNum = garminByteBufferReader.readShort();
final GlobalFITMessage globalFITMessage = GlobalFITMessage.fromNumber(globalMesgNum);
RecordDefinition definitionMessage = new RecordDefinition(byteOrder, recordHeader, globalFITMessage, null);
final int numFields = garminByteBufferReader.readByte();
List<FieldDefinition> fieldDefinitions = new ArrayList<>(numFields);
for (int i = 0; i < numFields; i++) {
fieldDefinitions.add(FieldDefinition.parseIncoming(garminByteBufferReader, globalFITMessage));
}
definitionMessage.setFieldDefinitions(fieldDefinitions);
if (recordHeader.isDeveloperData()) {
final int numDevFields = garminByteBufferReader.readByte();
List<DevFieldDefinition> devFieldDefinitions = new ArrayList<>(numDevFields);
for (int i = 0; i < numDevFields; i++) {
devFieldDefinitions.add(DevFieldDefinition.parseIncoming(garminByteBufferReader));
}
definitionMessage.setDevFieldDefinitions(devFieldDefinitions);
}
return definitionMessage;
}
public GlobalFITMessage getGlobalFITMessage() {
return globalFITMessage;
}
public ByteOrder getByteOrder() {
return byteOrder;
}
public List<DevFieldDefinition> getDevFieldDefinitions() {
return devFieldDefinitions;
}
public void setDevFieldDefinitions(List<DevFieldDefinition> devFieldDefinitions) {
this.devFieldDefinitions = devFieldDefinitions;
}
public RecordHeader getRecordHeader() {
return recordHeader;
}
@Nullable
public List<FieldDefinition> getFieldDefinitions() {
return fieldDefinitions;
}
public void setFieldDefinitions(List<FieldDefinition> fieldDefinitions) {
this.fieldDefinitions = fieldDefinitions;
}
public void generateOutgoingPayload(MessageWriter writer) {
writer.writeByte(recordHeader.generateOutgoingDefinitionPayload());
writer.writeByte(0);//ignore
writer.writeByte(byteOrder == ByteOrder.LITTLE_ENDIAN ? 0 : 1);
writer.setByteOrder(byteOrder);
writer.writeShort(globalFITMessage.getNumber());
if (fieldDefinitions != null) {
writer.writeByte(fieldDefinitions.size());
for (FieldDefinition fieldDefinition : fieldDefinitions) {
fieldDefinition.generateOutgoingPayload(writer);
}
}
}
public String getName() {
return predefinedLocalMessage != null ? predefinedLocalMessage.name() : "unknown_" + globalFITMessage;
}
@NonNull
public String toString() {
return System.lineSeparator() + recordHeader.toString() +
" Global Message Number: " + globalFITMessage.name();
}
public void populateDevFields(List<RecordData> developerFieldData) {
for (DevFieldDefinition devFieldDef :
getDevFieldDefinitions()) {
for (RecordData recordData :
developerFieldData) {
try {
if (devFieldDef.getFieldDefinitionNumber() == (int) recordData.getFieldByName("field_definition_number") &&
devFieldDef.getDeveloperDataIndex() == (int) recordData.getFieldByName("developer_data_index")) {
BaseType baseType = BaseType.fromIdentifier((int) recordData.getFieldByName("fit_base_type_id"));
devFieldDef.setBaseType(baseType);
devFieldDef.setName((String) recordData.getFieldByName("field_name"));
}
} catch (Exception e) {
//ignore
}
}
}
}
}

View File

@ -0,0 +1,121 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
public class RecordHeader {
private final boolean definition;
private final boolean developerData;
private final PredefinedLocalMessage predefinedLocalMessage;
private final int rawLocalMessageType;
private final Integer timeOffset;
private long referenceTimestamp;
public RecordHeader(boolean definition, boolean developerData, PredefinedLocalMessage predefinedLocalMessage, Integer timeOffset) {
this.definition = definition;
this.developerData = developerData;
this.predefinedLocalMessage = predefinedLocalMessage;
this.rawLocalMessageType = predefinedLocalMessage.getType();
this.timeOffset = timeOffset;
}
//see https://github.com/polyvertex/fitdecode/blob/master/fitdecode/reader.py#L512
public RecordHeader(byte header) {
this(header, false);
}
public RecordHeader(byte header, boolean inferLocalMessage) {
if ((header & 0x80) == 0x80) { //compressed timestamp TODO add support
definition = false;
developerData = false;
rawLocalMessageType = (header >> 5) & 0x3;
timeOffset = header & 0x1f;
} else {
definition = ((header & 0x40) == 0x40);
developerData = ((header & 0x20) == 0x20);
rawLocalMessageType = header & 0xf;
timeOffset = null;
}
if (inferLocalMessage)
predefinedLocalMessage = PredefinedLocalMessage.fromType(rawLocalMessageType);
else
predefinedLocalMessage = null;
}
public void setReferenceTimestamp(long referenceTimestamp) {
this.referenceTimestamp = referenceTimestamp;
}
public Integer getTimeOffset() {
return timeOffset;
}
public boolean isCompressedTimestamp() {
return timeOffset != null;
}
public Long getResultingTimestamp() {
return referenceTimestamp + timeOffset;
}
public boolean isDeveloperData() {
return developerData;
}
public boolean isDefinition() {
return definition;
}
@Nullable
public PredefinedLocalMessage getPredefinedLocalMessage() {
return predefinedLocalMessage;
}
public byte generateOutgoingDefinitionPayload() {
if (!definition && !developerData)
return (byte) (timeOffset | (((byte) predefinedLocalMessage.getType()) << 5));
byte base = (byte) (null == predefinedLocalMessage ? rawLocalMessageType : predefinedLocalMessage.getType());
if (definition)
base = (byte) (base | 0x40);
if (developerData)
base = (byte) (base | 0x20);
return base;
}
public byte generateOutgoingDataPayload() { //TODO: unclear if correct
if (!definition && !developerData)
return (byte) (timeOffset | (((byte) predefinedLocalMessage.getType()) << 5));
byte base = (byte) (null == predefinedLocalMessage ? rawLocalMessageType : predefinedLocalMessage.getType());
if (developerData)
base = (byte) (base | 0x20);
return base;
}
@NonNull
@Override
public String toString() {
return "Local Message: " + (null == predefinedLocalMessage ? "raw: " + rawLocalMessageType : "type: " + predefinedLocalMessage.name());
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
RecordHeader that = (RecordHeader) o;
if (rawLocalMessageType != that.rawLocalMessageType) return false;
return predefinedLocalMessage == that.predefinedLocalMessage;
}
@Override
public int hashCode() {
int result = (predefinedLocalMessage != null ? predefinedLocalMessage.hashCode() : 0);
result = 31 * result + rawLocalMessageType;
return result;
}
}

View File

@ -0,0 +1,62 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes;
import java.nio.ByteBuffer;
//see https://github.com/dtcooper/python-fitparse/blob/master/fitparse/records.py
public enum BaseType {
ENUM(0x00, new BaseTypeByte(true, 0xFF)),
SINT8(0x01, new BaseTypeByte(false, 0x7F)),
UINT8(0x02, new BaseTypeByte(true, 0xFF)),
SINT16(0x83, new BaseTypeShort(false, 0x7FFF)),
UINT16(0x84, new BaseTypeShort(true, 0xFFFF)),
SINT32(0x85, new BaseTypeInt(false, 0x7FFFFFFF)),
UINT32(0x86, new BaseTypeInt(true, 0xFFFFFFFFL)),
STRING(0x07, new BaseTypeByte(true, 0x00)),
FLOAT32(0x88, new BaseTypeFloat()),
FLOAT64(0x89, new BaseTypeDouble()),
UINT8Z(0x0A, new BaseTypeByte(true, 0x00)),
UINT16Z(0x8B, new BaseTypeShort(true, 0)),
UINT32Z(0x8C, new BaseTypeInt(true, 0)),
BASE_TYPE_BYTE(0x0D, new BaseTypeByte(true, 0xFF)),
SINT64(0x8E, new BaseTypeLong(false, 0x7FFFFFFFFFFFFFFFL)),
UINT64(0x8F, new BaseTypeLong(true, 0xFFFFFFFFFFFFFFFFL)),
UINT64Z(0x8F, new BaseTypeLong(true, 0)),
;
private final int identifier;
private final BaseTypeInterface baseTypeInterface;
BaseType(int identifier, BaseTypeInterface byteBaseType) {
this.identifier = identifier;
this.baseTypeInterface = byteBaseType;
}
public static BaseType fromIdentifier(int identifier) {
for (final BaseType baseType : BaseType.values()) {
if (baseType.getIdentifier() == identifier) {
return baseType;
}
}
throw new IllegalArgumentException("Unknown type " + identifier);
}
public int getSize() {
return baseTypeInterface.getByteSize();
}
public int getIdentifier() {
return identifier;
}
public Object decode(ByteBuffer byteBuffer, int scale, int offset) {
return baseTypeInterface.decode(byteBuffer, scale, offset);
}
public void encode(ByteBuffer byteBuffer, Object o, int scale, int offset) {
baseTypeInterface.encode(byteBuffer, o, scale, offset);
}
public void invalidate(ByteBuffer byteBuffer) {
baseTypeInterface.invalidate(byteBuffer);
}
}

View File

@ -0,0 +1,59 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes;
import java.nio.ByteBuffer;
public class BaseTypeByte implements BaseTypeInterface {
private final int min;
private final int max;
private final int invalid;
private final boolean unsigned;
private final int size = 1;
BaseTypeByte(boolean unsigned, int invalid) {
if (unsigned) {
min = 0;
max = 0xff;
} else {
min = Byte.MIN_VALUE;
max = Byte.MAX_VALUE;
}
this.invalid = invalid;
this.unsigned = unsigned;
}
public int getByteSize() {
return size;
}
@Override
public Object decode(final ByteBuffer byteBuffer, int scale, int offset) {
int b = unsigned ? Byte.toUnsignedInt(byteBuffer.get()) : byteBuffer.get();
if (b < min || b > max)
return null;
if (b == invalid)
return null;
return (b + offset) / scale;
}
@Override
public void encode(ByteBuffer byteBuffer, Object o, int scale, int offset) {
if (null == o) {
invalidate(byteBuffer);
return;
}
int i = ((Number) o).intValue() * scale - offset;
if (i < min || i > max) {
invalidate(byteBuffer);
return;
}
byteBuffer.put((byte) i);
}
@Override
public void invalidate(ByteBuffer byteBuffer) {
byteBuffer.put((byte) invalid);
}
}

View File

@ -0,0 +1,50 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes;
import java.nio.ByteBuffer;
public class BaseTypeDouble implements BaseTypeInterface {
private final int size = 8;
private final double min;
private final double max;
private final double invalid;
BaseTypeDouble() {
this.min = -Double.MAX_VALUE;
this.max = Double.MAX_VALUE;
this.invalid = Double.longBitsToDouble(0xFFFFFFFFFFFFFFFFL);
}
public int getByteSize() {
return size;
}
@Override
public Object decode(final ByteBuffer byteBuffer, int scale, int offset) {
double d = byteBuffer.getDouble();
if (d < min || d > max) {
return null;
}
if (Double.isNaN(d) || d == invalid)
return null;
return (d + offset) / scale;
}
@Override
public void encode(ByteBuffer byteBuffer, Object o, int scale, int offset) {
if (null == o) {
invalidate(byteBuffer);
return;
}
double d = ((Number) o).doubleValue() * scale - offset;
if (d < min || d > max) {
invalidate(byteBuffer);
return;
}
byteBuffer.putDouble(d);
}
@Override
public void invalidate(ByteBuffer byteBuffer) {
byteBuffer.putDouble(invalid);
}
}

View File

@ -0,0 +1,51 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes;
import java.nio.ByteBuffer;
public class BaseTypeFloat implements BaseTypeInterface {
private final int size = 4;
private final double min;
private final double max;
private final double invalid;
BaseTypeFloat() {
this.min = -Float.MAX_VALUE;
this.max = Float.MAX_VALUE;
this.invalid = Float.intBitsToFloat(0xFFFFFFFF);
}
public int getByteSize() {
return size;
}
@Override
public Object decode(ByteBuffer byteBuffer, int scale, int offset) {
float f = byteBuffer.getFloat();
if (f < min || f > max) {
return null;
}
if (Float.isNaN(f) || f == invalid)
return null;
return (f + offset) / scale;
}
@Override
public void encode(ByteBuffer byteBuffer, Object o, int scale, int offset) {
if (null == o) {
invalidate(byteBuffer);
return;
}
float f = ((Number) o).floatValue() * scale - offset;
if (f < min || f > max) {
invalidate(byteBuffer);
return;
}
byteBuffer.putFloat((float) f);
}
@Override
public void invalidate(ByteBuffer byteBuffer) {
byteBuffer.putFloat((float) invalid);
}
}

View File

@ -0,0 +1,57 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes;
import java.nio.ByteBuffer;
public class BaseTypeInt implements BaseTypeInterface {
private final long min;
private final long max;
private final long invalid;
private final boolean unsigned;
private final int size = 4;
BaseTypeInt(boolean unsigned, long invalid) {
if (unsigned) {
this.min = 0;
this.max = 0xffffffffL;
} else {
this.min = Integer.MIN_VALUE;
this.max = Integer.MAX_VALUE;
}
this.invalid = invalid;
this.unsigned = unsigned;
}
public int getByteSize() {
return size;
}
@Override
public Object decode(final ByteBuffer byteBuffer, int scale, int offset) {
long i = unsigned ? Integer.toUnsignedLong(byteBuffer.getInt()) : byteBuffer.getInt();
if (i < min || i > max)
return null;
if (i == invalid)
return null;
return ((i + offset) / scale);
}
@Override
public void encode(ByteBuffer byteBuffer, Object o, int scale, int offset) {
if (null == o) {
invalidate(byteBuffer);
return;
}
long l = ((Number) o).longValue() * scale - offset;
if (l < min || l > max) {
invalidate(byteBuffer);
return;
}
byteBuffer.putInt((int) l);
}
@Override
public void invalidate(ByteBuffer byteBuffer) {
byteBuffer.putInt((int) invalid);
}
}

View File

@ -0,0 +1,13 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes;
import java.nio.ByteBuffer;
public interface BaseTypeInterface {
int getByteSize();
Object decode(ByteBuffer byteBuffer, int scale, int offset);
void encode(ByteBuffer byteBuffer, Object o, int scale, int offset);
void invalidate(ByteBuffer byteBuffer);
}

View File

@ -0,0 +1,58 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes;
import java.math.BigInteger;
import java.nio.ByteBuffer;
public class BaseTypeLong implements BaseTypeInterface {
private final int size = 8;
private final BigInteger min;
private final BigInteger max;
private final long invalid;
private final boolean unsigned;
BaseTypeLong(boolean unsigned, long invalid) {
if (unsigned) {
this.min = BigInteger.valueOf(0);
this.max = BigInteger.valueOf(0xFFFFFFFFFFFFFFFFL);
} else {
this.min = BigInteger.valueOf(Long.MIN_VALUE);
this.max = BigInteger.valueOf(Long.MAX_VALUE);
}
this.invalid = invalid;
this.unsigned = unsigned;
}
public int getByteSize() {
return size;
}
@Override
public Object decode(ByteBuffer byteBuffer, int scale, int offset) {
BigInteger i = unsigned ? BigInteger.valueOf(byteBuffer.getLong() & 0xFFFFFFFFFFFFFFFFL) : BigInteger.valueOf(byteBuffer.getLong());
if (!unsigned && (i.compareTo(min) < 0 || i.compareTo(max) > 0))
return null;
if (i.compareTo(BigInteger.valueOf(invalid)) == 0)
return null;
return (i.longValue() + offset) / scale;
}
@Override
public void encode(ByteBuffer byteBuffer, Object o, int scale, int offset) {
if (null == o) {
invalidate(byteBuffer);
return;
}
BigInteger i = BigInteger.valueOf(((Number) o).longValue() * scale - offset);
if (!unsigned && (i.compareTo(min) < 0 || i.compareTo(max) > 0)) {
invalidate(byteBuffer);
return;
}
byteBuffer.putLong(i.longValue());
}
@Override
public void invalidate(ByteBuffer byteBuffer) {
byteBuffer.putLong((long) invalid);
}
}

View File

@ -0,0 +1,56 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes;
import java.nio.ByteBuffer;
public class BaseTypeShort implements BaseTypeInterface {
private final int min;
private final int max;
private final int invalid;
private final boolean unsigned;
private final int size = 2;
BaseTypeShort(boolean unsigned, int invalid) {
if (unsigned) {
this.min = 0;
this.max = 0xffff;
} else {
this.min = Short.MIN_VALUE;
this.max = Short.MAX_VALUE;
}
this.invalid = invalid;
this.unsigned = unsigned;
}
public int getByteSize() {
return size;
}
@Override
public Object decode(final ByteBuffer byteBuffer, int scale, int offset) {
int s = unsigned ? Short.toUnsignedInt(byteBuffer.getShort()) : byteBuffer.getShort();
if (s < min || s > max)
return null;
if (s == invalid)
return null;
return (s + offset) / scale;
}
@Override
public void encode(ByteBuffer byteBuffer, Object o, int scale, int offset) {
if (null == o) {
invalidate(byteBuffer);
return;
}
int i = ((Number) o).intValue() * scale - offset;
if (i < min || i > max) {
invalidate(byteBuffer);
return;
}
byteBuffer.putShort((short) i);
}
@Override
public void invalidate(ByteBuffer byteBuffer) {
byteBuffer.putShort((short) invalid);
}
}

View File

@ -0,0 +1,31 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions;
import java.nio.ByteBuffer;
import java.util.Calendar;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FieldDefinition;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
public class FieldDefinitionAlarm extends FieldDefinition {
public FieldDefinitionAlarm(int localNumber, int size, BaseType baseType, String name) {
super(localNumber, size, baseType, name, 1, 0);
}
@Override
public Object decode(ByteBuffer byteBuffer) {
int raw = (int) baseType.decode(byteBuffer, scale, offset);
Calendar calendar = Calendar.getInstance();
calendar.set(Calendar.HOUR_OF_DAY, Math.round(raw / 60));
calendar.set(Calendar.MINUTE, raw % 60);
return calendar;
}
@Override
public void encode(ByteBuffer byteBuffer, Object o) {
if (o instanceof Calendar) {
baseType.encode(byteBuffer, ((Calendar) o).get(Calendar.HOUR_OF_DAY) * 60 + ((Calendar) o).get(Calendar.MINUTE), scale, offset);
return;
}
baseType.encode(byteBuffer, o, scale, offset);
}
}

View File

@ -0,0 +1,32 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions;
import org.threeten.bp.DayOfWeek;
import org.threeten.bp.Instant;
import org.threeten.bp.ZoneId;
import java.nio.ByteBuffer;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FieldDefinition;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
public class FieldDefinitionDayOfWeek extends FieldDefinition {
public FieldDefinitionDayOfWeek(int localNumber, int size, BaseType baseType, String name) {
super(localNumber, size, baseType, name, 1, 0);
}
@Override
public Object decode(ByteBuffer byteBuffer) {
int raw = (int) baseType.decode(byteBuffer, scale, offset);
return DayOfWeek.of(raw == 0 ? 7 : raw);
}
@Override
public void encode(ByteBuffer byteBuffer, Object o) {
if (o instanceof DayOfWeek) {
baseType.encode(byteBuffer, (((DayOfWeek) o).getValue() % 7), scale, offset);
return;
}
baseType.encode(byteBuffer, (Instant.ofEpochSecond((int) o).atZone(ZoneId.systemDefault()).getDayOfWeek().getValue() % 7), scale, offset);
}
}

View File

@ -0,0 +1,62 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions;
import androidx.annotation.Nullable;
import java.nio.ByteBuffer;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FieldDefinition;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
public class FieldDefinitionFileType extends FieldDefinition {
public FieldDefinitionFileType(int localNumber, int size, BaseType baseType, String name) {
super(localNumber, size, baseType, name, 1, 0);
}
@Override
public Object decode(ByteBuffer byteBuffer) {
int raw = (int) baseType.decode(byteBuffer, scale, offset);
return Type.fromId(raw) == null ? raw : Type.fromId(raw);
}
@Override
public void encode(ByteBuffer byteBuffer, Object o) {
if (o instanceof Type) {
baseType.encode(byteBuffer, (((Type) o).getId()), scale, offset);
return;
}
baseType.encode(byteBuffer, o, scale, offset);
}
public enum Type {
settings(2),
activity(4), //FIT_TYPE_4 stands for activity directory
goals(11),
monitor(32), //FIT_TYPE_32
changelog(41), // FIT_TYPE_41 stands for changelog directory
metrics(44), //FIT_TYPE_41
sleep(49), //FIT_TYPE_49
;
private final int id;
Type(int i) {
this.id = i;
}
@Nullable
public static Type fromId(int id) {
for (Type type :
Type.values()) {
if (id == type.getId()) {
return type;
}
}
return null;
}
public int getId() {
return this.id;
}
}
}

View File

@ -0,0 +1,48 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions;
import androidx.annotation.Nullable;
import java.nio.ByteBuffer;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FieldDefinition;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
public class FieldDefinitionGoalSource extends FieldDefinition {
public FieldDefinitionGoalSource(int localNumber, int size, BaseType baseType, String name) {
super(localNumber, size, baseType, name, 1, 0);
}
@Override
public Object decode(ByteBuffer byteBuffer) {
int raw = (int) baseType.decode(byteBuffer, scale, offset);
return Source.fromId(raw);
}
@Override
public void encode(ByteBuffer byteBuffer, Object o) {
if (o instanceof Source) {
baseType.encode(byteBuffer, (((Source) o).ordinal()), scale, offset);
return;
}
baseType.encode(byteBuffer, o, scale, offset);
}
public enum Source {
auto,
community,
manual,
;
@Nullable
public static Source fromId(int id) {
for (Source source :
Source.values()) {
if (id == source.ordinal()) {
return source;
}
}
return null;
}
}
}

View File

@ -0,0 +1,56 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions;
import androidx.annotation.Nullable;
import java.nio.ByteBuffer;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FieldDefinition;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
public class FieldDefinitionGoalType extends FieldDefinition {
public FieldDefinitionGoalType(int localNumber, int size, BaseType baseType, String name) {
super(localNumber, size, baseType, name, 1, 0);
}
@Override
public Object decode(ByteBuffer byteBuffer) {
int raw = (int) baseType.decode(byteBuffer, scale, offset);
return Type.fromId(raw);
}
@Override
public void encode(ByteBuffer byteBuffer, Object o) {
if (o instanceof Type) {
baseType.encode(byteBuffer, (((Type) o).getId()), scale, offset);
return;
}
baseType.encode(byteBuffer, o, scale, offset);
}
public enum Type {
steps(4),
;
private final int id;
Type(int i) {
id = i;
}
@Nullable
public static Type fromId(int id) {
for (Type type :
Type.values()) {
if (id == type.getId()) {
return type;
}
}
return null;
}
public int getId() {
return id;
}
}
}

View File

@ -0,0 +1,57 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions;
import androidx.annotation.Nullable;
import java.nio.ByteBuffer;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FieldDefinition;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
public class FieldDefinitionLanguage extends FieldDefinition {
public FieldDefinitionLanguage(int localNumber, int size, BaseType baseType, String name) {
super(localNumber, size, baseType, name, 1, 0);
}
@Override
public Object decode(ByteBuffer byteBuffer) {
int raw = (int) baseType.decode(byteBuffer, scale, offset);
return Language.fromId(raw);
}
@Override
public void encode(ByteBuffer byteBuffer, Object o) {
if (o instanceof Language) {
baseType.encode(byteBuffer, (((Language) o).getId()), scale, offset);
return;
}
baseType.encode(byteBuffer, o, scale, offset);
}
private enum Language {
english(0),
italian(2),
;
private final int id;
Language(int i) {
id = i;
}
@Nullable
public static Language fromId(int id) {
for (Language language :
Language.values()) {
if (id == language.getId()) {
return language;
}
}
return null;
}
public int getId() {
return id;
}
}
}

View File

@ -0,0 +1,44 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions;
import java.nio.ByteBuffer;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FieldDefinition;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
public class FieldDefinitionMeasurementSystem extends FieldDefinition {
public FieldDefinitionMeasurementSystem(int localNumber, int size, BaseType baseType, String name) {
super(localNumber, size, baseType, name, 1, 0);
}
@Override
public Object decode(ByteBuffer byteBuffer) {
int raw = (int) baseType.decode(byteBuffer, scale, offset);
return Type.fromId(raw) == null ? raw : Type.fromId(raw);
}
@Override
public void encode(ByteBuffer byteBuffer, Object o) {
if (o instanceof Type) {
baseType.encode(byteBuffer, (((Type) o).ordinal()), scale, offset);
return;
}
baseType.encode(byteBuffer, o, scale, offset);
}
public enum Type {
metric,
;
public static Type fromId(int id) {
for (Type type :
Type.values()) {
if (type.ordinal() == id) {
return type;
}
}
return null;
}
}
}

View File

@ -0,0 +1,12 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FieldDefinition;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
public class FieldDefinitionTemperature extends FieldDefinition {
public FieldDefinitionTemperature(int localNumber, int size, BaseType baseType, String name) {
super(localNumber, size, baseType, name, 1, 273);
}
}

View File

@ -0,0 +1,26 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FieldDefinition;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
import static nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminTimeUtils.GARMIN_TIME_EPOCH;
public class FieldDefinitionTimestamp extends FieldDefinition {
public FieldDefinitionTimestamp(int localNumber, int size, BaseType baseType, String name) {
super(localNumber, size, baseType, name, 1, GARMIN_TIME_EPOCH);
}
// @Override
// public Object decode(ByteBuffer byteBuffer) {
// return new Timestamp((long) baseType.decode(byteBuffer, scale, offset) * 1000L);
// }
//
// @Override
// public void encode(ByteBuffer byteBuffer, Object o) {
// if(o instanceof Timestamp) {
// baseType.encode(byteBuffer, (int) (((Timestamp) o).getTime() / 1000L), scale, offset);
// return;
// }
// baseType.encode(byteBuffer, o, scale, offset);
// }
}

View File

@ -0,0 +1,171 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions;
import java.nio.ByteBuffer;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FieldDefinition;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
public class FieldDefinitionWeatherCondition extends FieldDefinition {
public FieldDefinitionWeatherCondition(int localNumber, int size, BaseType baseType, String name) {
super(localNumber, size, baseType, name, 1, 0);
}
@Override
public Object decode(ByteBuffer byteBuffer) {
int idx = (int) baseType.decode(byteBuffer, scale, offset);
return Condition.values()[idx];
}
@Override
public void encode(ByteBuffer byteBuffer, Object o) {
if (o instanceof Condition) {
baseType.encode(byteBuffer, ((Condition) o).ordinal(), scale, offset);
return;
}
baseType.encode(byteBuffer, openWeatherCodeToFitWeatherStatus((int) o), scale, offset);
}
private int openWeatherCodeToFitWeatherStatus(int openWeatherCode) {
switch (openWeatherCode) {
//Group 2xx: Thunderstorm
case 200: //thunderstorm with light rain: //11d
case 201: //thunderstorm with rain: //11d
case 202: //thunderstorm with heavy rain: //11d
case 210: //light thunderstorm:: //11d
case 211: //thunderstorm: //11d
case 212: //heavy thunderstorm: //11d
case 230: //thunderstorm with light drizzle: //11d
case 231: //thunderstorm with drizzle: //11d
case 232: //thunderstorm with heavy drizzle: //11d
return Condition.THUNDERSTORMS.ordinal();
case 221: //ragged thunderstorm: //11d
return Condition.SCATTERED_THUNDERSTORMS.ordinal();
//Group 3xx: Drizzle
case 300: //light intensity drizzle: //09d
case 310: //light intensity drizzle rain: //09d
case 313: //shower rain and drizzle: //09d
return Condition.LIGHT_RAIN.ordinal();
case 301: //drizzle: //09d
case 311: //drizzle rain: //09d
return Condition.RAIN.ordinal();
case 302: //heavy intensity drizzle: //09d
case 312: //heavy intensity drizzle rain: //09d
case 314: //heavy shower rain and drizzle: //09d
return Condition.HEAVY_RAIN.ordinal();
case 321: //shower drizzle: //09d
return Condition.SCATTERED_SHOWERS.ordinal();
//Group 5xx: Rain
case 500: //light rain: //10d
case 520: //light intensity shower rain: //09d
case 521: //shower rain: //09d
return Condition.LIGHT_RAIN.ordinal();
case 501: //moderate rain: //10d
case 531: //ragged shower rain: //09d
return Condition.RAIN.ordinal();
case 502: //heavy intensity rain: //10d
case 503: //very heavy rain: //10d
case 504: //extreme rain: //10d
case 522: //heavy intensity shower rain: //09d
return Condition.HEAVY_RAIN.ordinal();
case 511: //freezing rain: //13d
return Condition.UNKNOWN_PRECIPITATION.ordinal();
//Group 6xx: Snow
case 600: //light snow: //[[file:13d.png]]
return Condition.LIGHT_SNOW.ordinal();
case 601: //snow: //[[file:13d.png]]
case 620: //light shower snow: //[[file:13d.png]]
case 621: //shower snow: //[[file:13d.png]]
return Condition.SNOW.ordinal();
case 602: //heavy snow: //[[file:13d.png]]
case 622: //heavy shower snow: //[[file:13d.png]]
return Condition.HEAVY_SNOW.ordinal();
case 611: //sleet: //[[file:13d.png]]
case 612: //light shower sleet: //[[file:13d.png]]
case 613: //shower sleet: //[[file:13d.png]]
return Condition.WINTRY_MIX.ordinal();
case 615: //light rain and snow: //[[file:13d.png]]
return Condition.LIGHT_RAIN_SNOW.ordinal();
case 616: //rain and snow: //[[file:13d.png]]
return Condition.HEAVY_RAIN_SNOW.ordinal();
//Group 7xx: Atmosphere
case 701: //mist: //[[file:50d.png]]
case 711: //smoke: //[[file:50d.png]]
case 721: //haze: //[[file:50d.png]]
case 731: //sandcase dust whirls: //[[file:50d.png]]
case 751: //sand: //[[file:50d.png]]
case 761: //dust: //[[file:50d.png]]
case 762: //volcanic ash: //[[file:50d.png]]
return Condition.HAZY.ordinal();
case 741: //fog: //[[file:50d.png]]
return Condition.FOG.ordinal();
case 771: //squalls: //[[file:50d.png]]
case 781: //tornado: //[[file:50d.png]]
return Condition.WINDY.ordinal();
//Group 800: Clear
case 800: //clear sky: //[[file:01d.png]] [[file:01n.png]]
return Condition.CLEAR.ordinal();
//Group 80x: Clouds
case 801: //few clouds: //[[file:02d.png]] [[file:02n.png]]
case 802: //scattered clouds: //[[file:03d.png]] [[file:03d.png]]
return Condition.PARTLY_CLOUDY.ordinal();
case 803: //broken clouds: //[[file:04d.png]] [[file:03d.png]]
return Condition.MOSTLY_CLOUDY.ordinal();
case 804: //overcast clouds: //[[file:04d.png]] [[file:04d.png]]
return Condition.CLOUDY.ordinal();
//Group 90x: Extreme
case 901: //tropical storm
return Condition.THUNDERSTORMS.ordinal();
case 906: //hail
return Condition.HAIL.ordinal();
case 903: //cold
case 904: //hot
case 905: //windy
//Group 9xx: Additional
case 951: //calm
case 952: //light breeze
case 953: //gentle breeze
case 954: //moderate breeze
case 955: //fresh breeze
case 956: //strong breeze
case 957: //high windcase near gale
case 958: //gale
case 959: //severe gale
case 960: //storm
case 961: //violent storm
case 902: //hurricane
case 962: //hurricane
default:
throw new IllegalArgumentException("Unknown weather code " + openWeatherCode);
}
}
enum Condition {
CLEAR,
PARTLY_CLOUDY,
MOSTLY_CLOUDY,
RAIN,
SNOW,
WINDY,
THUNDERSTORMS,
WINTRY_MIX,
FOG,
UNK9,
UNK10,
HAZY,
HAIL,
SCATTERED_SHOWERS,
SCATTERED_THUNDERSTORMS,
UNKNOWN_PRECIPITATION,
LIGHT_RAIN,
HEAVY_RAIN,
LIGHT_SNOW,
HEAVY_SNOW,
LIGHT_RAIN_SNOW,
HEAVY_RAIN_SNOW,
CLOUDY,
;
}
}

View File

@ -0,0 +1,160 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.http;
import com.google.protobuf.ByteString;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.atomic.AtomicInteger;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiDataTransferService;
public class DataTransferHandler {
private static final Logger LOG = LoggerFactory.getLogger(DataTransferHandler.class);
private static final AtomicInteger idCounter = new AtomicInteger(0);
private static final Map<Integer, Data> dataById = new HashMap<>();
private static final Map<Integer, ChunkInfo> unprocessedChunksByRequestId = new HashMap<>();
public GdiDataTransferService.DataTransferService handle(
final GdiDataTransferService.DataTransferService dataTransferService,
final int requestId
) {
if (dataTransferService.hasDataDownloadRequest()) {
final GdiDataTransferService.DataTransferService.DataDownloadResponse dataDownloadResponse
= handleDataDownloadRequest(dataTransferService.getDataDownloadRequest(), requestId);
if (dataDownloadResponse != null) {
return GdiDataTransferService.DataTransferService.newBuilder()
.setDataDownloadResponse(dataDownloadResponse)
.build();
}
return null;
}
LOG.warn("Unsupported data transfer service request: {}", dataTransferService);
return null;
}
public GdiDataTransferService.DataTransferService.DataDownloadResponse handleDataDownloadRequest(
final GdiDataTransferService.DataTransferService.DataDownloadRequest dataDownloadRequest,
final int requestId
) {
final int dataId = dataDownloadRequest.getId();
final int offset = dataDownloadRequest.getOffset();
LOG.debug("Received data download request (id: {}, offset: {})", dataId, offset);
final Data data = dataById.get(dataId);
if (data == null) {
LOG.error("Device requested data with invalid id: {}", dataId);
return GdiDataTransferService.DataTransferService.DataDownloadResponse.newBuilder()
.setStatus(GdiDataTransferService.DataTransferService.Status.INVALID_ID)
.setId(dataId)
.setOffset(offset)
.build();
}
final int maxChunkSize = dataDownloadRequest.hasMaxChunkSize() ? dataDownloadRequest.getMaxChunkSize() : Integer.MAX_VALUE;
final byte[] chunk = data.getDataChunk(offset, maxChunkSize);
if (chunk == null) {
LOG.error("Device requested data with invalid offset: {}", offset);
return GdiDataTransferService.DataTransferService.DataDownloadResponse.newBuilder()
.setStatus(GdiDataTransferService.DataTransferService.Status.INVALID_OFFSET)
.setId(dataId)
.setOffset(offset)
.build();
}
unprocessedChunksByRequestId.put(requestId, new ChunkInfo(dataId, offset, offset + chunk.length));
return GdiDataTransferService.DataTransferService.DataDownloadResponse.newBuilder()
.setStatus(GdiDataTransferService.DataTransferService.Status.SUCCESS)
.setId(dataId)
.setOffset(offset)
.setPayload(ByteString.copyFrom(chunk))
.build();
}
public static int registerData(final byte[] data) {
int id = idCounter.getAndIncrement();
LOG.info("New data will be sent to the device (id: {}, size: {})", id, data.length);
dataById.put(id, new Data(data));
return id;
}
public static void onDataChunkSuccessfullyReceived(final int requestId) {
final ChunkInfo chunkInfo = unprocessedChunksByRequestId.get(requestId);
if (chunkInfo == null) {
return;
}
unprocessedChunksByRequestId.remove(requestId);
final Data data = dataById.get(chunkInfo.dataId);
if (data == null) {
return;
}
data.onDataChunkSuccessfullyReceived(chunkInfo);
if (data.isDataSuccessfullySent()) {
LOG.info("Data successfully sent to the device (id: {}, size: {})", chunkInfo.dataId, data.data.length);
dataById.remove(chunkInfo.dataId);
} else {
LOG.debug(
"Data chunk successfully sent to the device (dataId: {}, requestId: {}): {}-{}/{}",
chunkInfo.dataId, requestId, chunkInfo.start, chunkInfo.end, data.data.length
);
}
}
private static class ChunkInfo {
private final int dataId;
private final int start;
private final int end;
private ChunkInfo(int dataId, int start, int end) {
this.dataId = dataId;
this.start = start;
this.end = end;
}
}
private static class Data {
// TODO Wouldn't it be better to store data as streams?
// Because now we have to store the whole data in RAM.
private final byte[] data;
private final TreeMap<Integer, ChunkInfo> chunksReceivedByDevice;
private Data(byte[] data) {
this.data = data;
chunksReceivedByDevice = new TreeMap<>();
}
private byte[] getDataChunk(final int offset, final int maxChunkSize) {
if (offset < 0 || offset >= data.length) {
return null;
}
return Arrays.copyOfRange(data, offset, Math.min(offset + maxChunkSize, data.length));
}
private void onDataChunkSuccessfullyReceived(ChunkInfo newlyReceivedChunk) {
final ChunkInfo alreadyReceivedChunk = chunksReceivedByDevice.get(newlyReceivedChunk.start);
if (alreadyReceivedChunk == null || alreadyReceivedChunk.end < newlyReceivedChunk.end) {
chunksReceivedByDevice.put(newlyReceivedChunk.start, newlyReceivedChunk);
}
}
private boolean isDataSuccessfullySent() {
Integer previousChunkEnd = null;
for (Map.Entry<Integer, ChunkInfo> chunkEntry : chunksReceivedByDevice.entrySet()) {
if (previousChunkEnd == null && chunkEntry.getKey() != 0) {
// The head of the data wasn't received by the device.
return false;
}
if (previousChunkEnd != null && chunkEntry.getKey() > previousChunkEnd) {
// There is some gap between received chunks.
return false;
}
previousChunkEnd = chunkEntry.getValue().end;
}
// Check if the end of the last chunk matches the data size.
return previousChunkEnd != null && data.length == previousChunkEnd;
}
}
}

View File

@ -0,0 +1,41 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.http;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedInputStream;
import java.io.DataInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.Map;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupport;
public class EphemerisHandler {
private static final Logger LOG = LoggerFactory.getLogger(EphemerisHandler.class);
private final GarminSupport deviceSupport;
public EphemerisHandler(GarminSupport deviceSupport) {
this.deviceSupport = deviceSupport;
}
public byte[] handleEphemerisRequest(final String path, final Map<String, String> query) {
// TODO Return status code 304 (Not Modified) when we don't have newer data and "if-none-match" is set.
try {
final File exportDirectory = deviceSupport.getWritableExportDirectory();
final File ephemerisDataFile = new File(exportDirectory, "CPE.BIN");
if (!ephemerisDataFile.exists() || !ephemerisDataFile.isFile()) {
throw new IOException("Cannot locate CPE.BIN file in export/import directory.");
}
final byte[] bytes = new byte[(int) ephemerisDataFile.length()];
final BufferedInputStream bis = new BufferedInputStream(new FileInputStream(ephemerisDataFile));
final DataInputStream dis = new DataInputStream(bis);
dis.readFully(bytes);
return bytes;
} catch (IOException e) {
LOG.error("Unable to obtain ephemeris data.", e);
return null;
}
}
}

View File

@ -0,0 +1,159 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.http;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.protobuf.ByteString;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayOutputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.zip.GZIPOutputStream;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiHttpService;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupport;
import nodomain.freeyourgadget.gadgetbridge.util.HttpUtils;
public class HttpHandler {
private static final Logger LOG = LoggerFactory.getLogger(HttpHandler.class);
private static final Gson GSON = new GsonBuilder()
//.serializeNulls()
.create();
private final EphemerisHandler ephemerisHandler;
public HttpHandler(GarminSupport deviceSupport) {
ephemerisHandler = new EphemerisHandler(deviceSupport);
}
public GdiHttpService.HttpService handle(final GdiHttpService.HttpService httpService) {
if (httpService.hasRawRequest()) {
final GdiHttpService.HttpService.RawResponse rawResponse = handleRawRequest(httpService.getRawRequest());
if (rawResponse != null) {
return GdiHttpService.HttpService.newBuilder()
.setRawResponse(rawResponse)
.build();
}
return null;
}
LOG.warn("Unsupported http service request {}", httpService);
return null;
}
public GdiHttpService.HttpService.RawResponse handleRawRequest(final GdiHttpService.HttpService.RawRequest rawRequest) {
final String urlString = rawRequest.getUrl();
LOG.debug("Got rawRequest: {} - {}", rawRequest.getMethod(), urlString);
final URL url;
try {
url = new URL(urlString);
} catch (final MalformedURLException e) {
LOG.error("Failed to parse url", e);
return null;
}
final String path = url.getPath();
final Map<String, String> query = HttpUtils.urlQueryParameters(url);
if (path.startsWith("/weather/")) {
LOG.info("Got weather request for {}", path);
final Object weatherData = WeatherHandler.handleWeatherRequest(path, query);
if (weatherData == null) {
return null;
}
final String json = GSON.toJson(weatherData);
LOG.debug("Weather response: {}", json);
return createRawResponse(rawRequest, json.getBytes(StandardCharsets.UTF_8), "application/json");
} else if (path.startsWith("/ephemeris/")) {
LOG.info("Got ephemeris request for {}", path);
byte[] ephemerisData = ephemerisHandler.handleEphemerisRequest(path, query);
if (ephemerisData == null) {
return null;
}
LOG.debug("Successfully obtained ephemeris data (length: {})", ephemerisData.length);
return createRawResponse(rawRequest, ephemerisData, "application/x-tar");
} else {
LOG.warn("Unhandled path {}", urlString);
return null;
}
}
private static GdiHttpService.HttpService.RawResponse createRawResponse(
final GdiHttpService.HttpService.RawRequest rawRequest,
final byte[] data,
final String contentType
) {
if (rawRequest.hasUseDataXfer() && rawRequest.getUseDataXfer()) {
LOG.debug("Data will be returned using data_xfer");
int id = DataTransferHandler.registerData(data);
return GdiHttpService.HttpService.RawResponse.newBuilder()
.setStatus(GdiHttpService.HttpService.Status.OK)
.setHttpStatus(200)
.setXferData(
GdiHttpService.HttpService.DataTransferItem.newBuilder()
.setId(id)
.setSize(data.length)
.build()
)
.build();
}
final Map<String, String> requestHeaders = headersToMap(rawRequest.getHeaderList());
final List<GdiHttpService.HttpService.Header> responseHeaders = new ArrayList<>();
final byte[] responseBody;
if ("gzip".equals(requestHeaders.get("accept-encoding"))) {
responseHeaders.add(
GdiHttpService.HttpService.Header.newBuilder()
.setKey("Content-Encoding")
.setValue("gzip")
.build()
);
final ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (GZIPOutputStream gzos = new GZIPOutputStream(baos)) {
gzos.write(data);
gzos.finish();
gzos.flush();
responseBody = baos.toByteArray();
} catch (final Exception e) {
LOG.error("Failed to compress response", e);
return null;
}
} else {
responseBody = data;
}
responseHeaders.add(
GdiHttpService.HttpService.Header.newBuilder()
.setKey("Content-Type")
.setValue(contentType)
.build()
);
return GdiHttpService.HttpService.RawResponse.newBuilder()
.setStatus(GdiHttpService.HttpService.Status.OK)
.setHttpStatus(200)
.setBody(ByteString.copyFrom(responseBody))
.addAllHeader(responseHeaders)
.build();
}
private static Map<String, String> headersToMap(final List<GdiHttpService.HttpService.Header> headers) {
final Map<String, String> ret = new HashMap<>();
for (final GdiHttpService.HttpService.Header header : headers) {
ret.put(header.getKey().toLowerCase(Locale.ROOT), header.getValue());
}
return ret;
}
}

View File

@ -0,0 +1,375 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.http;
import android.location.Location;
import net.e175.klaus.solarpositioning.DeltaT;
import net.e175.klaus.solarpositioning.SPA;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.List;
import java.util.Map;
import nodomain.freeyourgadget.gadgetbridge.model.Weather;
import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
import nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.webview.CurrentPosition;
public class WeatherHandler {
private static final Logger LOG = LoggerFactory.getLogger(WeatherHandler.class);
// These get requested on connection at most every 5 minutes
public static Object handleWeatherRequest(final String path, final Map<String, String> query) {
final WeatherSpec weatherSpec = Weather.getInstance().getWeatherSpec();
if (weatherSpec == null) {
LOG.warn("No weather in weather instance");
return null;
}
switch (path) {
case "/weather/v2/forecast/day": {
final int lat = getQueryNum(query, "lat", 0);
final int lon = getQueryNum(query, "lon", 0);
final int duration = getQueryNum(query, "duration", 5);
final String tempUnit = getQueryString(query, "tempUnit", "CELSIUS");
final String provider = getQueryString(query, "provider", "dci");
final List<WeatherForecastDay> ret = new ArrayList<>(duration);
final GregorianCalendar date = new GregorianCalendar();
date.setTime(new Date(weatherSpec.timestamp * 1000L));
for (int i = 0; i < Math.min(duration, weatherSpec.forecasts.size()); i++) {
date.add(Calendar.DAY_OF_MONTH, 1);
ret.add(new WeatherForecastDay(date, weatherSpec.forecasts.get(i)));
}
return ret;
}
case "/weather/v2/forecast/hour": {
final int lat = getQueryNum(query, "lat", 0);
final int lon = getQueryNum(query, "lon", 0);
final int duration = getQueryNum(query, "duration", 13);
final String speedUnit = getQueryString(query, "speedUnit", "METERS_PER_SECOND");
final String tempUnit = getQueryString(query, "tempUnit", "CELSIUS");
final String provider = getQueryString(query, "provider", "dci");
final String timesOfInterest = getQueryString(query, "timesOfInterest", "");
final List<WeatherForecastHour> ret = new ArrayList<>(duration);
for (int i = 0; i < Math.min(duration, weatherSpec.hourly.size()); i++) {
ret.add(new WeatherForecastHour(weatherSpec.hourly.get(i)));
}
return ret;
}
case "/weather/v2/current": {
final int lat = getQueryNum(query, "lat", 0);
final int lon = getQueryNum(query, "lon", 0);
final String tempUnit = getQueryString(query, "tempUnit", "CELSIUS");
final String speedUnit = getQueryString(query, "speedUnit", "METERS_PER_SECOND");
final String provider = getQueryString(query, "provider", "dci");
return new WeatherForecastCurrent(weatherSpec);
}
}
LOG.warn("Unknown weather path {}", path);
return null;
}
private static int getQueryNum(final Map<String, String> query, final String key, final int defaultValue) {
final String str = query.get(key);
if (str != null) {
return Integer.parseInt(str);
} else {
return defaultValue;
}
}
private static String getQueryString(final Map<String, String> query, final String key, final String defaultValue) {
final String str = query.get(key);
if (str != null) {
return str;
} else {
return defaultValue;
}
}
public static class WeatherForecastDay {
public int dayOfWeek; // 1 monday .. 7 sunday
public String description;
public String summary;
public WeatherValue high;
public WeatherValue low;
public Integer precipProb;
public Integer icon;
public Integer epochSunrise;
public Integer epochSunset;
public Wind wind;
public Integer humidity;
public WeatherForecastDay(final GregorianCalendar date, final WeatherSpec.Daily dailyForecast) {
dayOfWeek = BLETypeConversions.dayOfWeekToRawBytes(date);
description = "Unknown"; // TODO from conditionCode
summary = "Unknown"; // TODO from conditionCode
high = new WeatherValue(dailyForecast.maxTemp - 273f, "CELSIUS");
low = new WeatherValue(dailyForecast.minTemp - 273f, "CELSIUS");
precipProb = dailyForecast.precipProbability;
icon = mapToCmfCondition(dailyForecast.conditionCode);
if (dailyForecast.sunRise != 0 && dailyForecast.sunSet != 0) {
epochSunrise = dailyForecast.sunRise;
epochSunset = dailyForecast.sunSet;
} else {
final Location lastKnownLocation = new CurrentPosition().getLastKnownLocation();
final GregorianCalendar[] sunriseTransitSet = SPA.calculateSunriseTransitSet(
date,
lastKnownLocation.getLatitude(),
lastKnownLocation.getLongitude(),
DeltaT.estimate(date)
);
epochSunrise = (int) (sunriseTransitSet[0].getTime().getTime() / 1000);
epochSunset = (int) (sunriseTransitSet[2].getTime().getTime() / 1000);
}
wind = new Wind(new WeatherValue(dailyForecast.windSpeed * 3.6, "METERS_PER_SECOND"), dailyForecast.windDirection);
humidity = dailyForecast.humidity;
}
}
public static class WeatherForecastHour {
public int epochSeconds;
public String description;
public WeatherValue temp;
public Integer precipProb;
public Wind wind;
public Integer icon;
public WeatherValue dewPoint;
public Float uvIndex;
public Integer relativeHumidity;
public WeatherValue feelsLikeTemperature;
public WeatherValue visibility;
public WeatherValue pressure;
public Object airQuality;
public Integer cloudCover;
public WeatherForecastHour(final WeatherSpec.Hourly hourlyForecast) {
epochSeconds = hourlyForecast.timestamp;
description = "Unknown"; // TODO from conditionCode
temp = new WeatherValue(hourlyForecast.temp - 273f, "CELSIUS");
precipProb = hourlyForecast.precipProbability;
wind = new Wind(new WeatherValue(hourlyForecast.windSpeed * 3.6, "METERS_PER_SECOND"), hourlyForecast.windDirection);
icon = mapToCmfCondition(hourlyForecast.conditionCode);
//dewPoint = new WeatherValue(hourlyForecast.temp - 273f, "CELSIUS"); // TODO dewPoint
uvIndex = hourlyForecast.uvIndex;
relativeHumidity = hourlyForecast.humidity;
//feelsLikeTemperature = new WeatherValue(hourlyForecast.temp - 273f, "CELSIUS"); // TODO feelsLikeTemperature
//visibility = new WeatherValue(0, "METER"); // TODO visibility
//pressure = new WeatherValue(0f, "INCHES_OF_MERCURY"); // TODO pressure
//airQuality = null; // TODO airQuality
//cloudCover = 0; // TODO cloudCover
}
}
public static class WeatherForecastCurrent {
public Integer epochSeconds;
public WeatherValue temperature;
public String description;
public Integer icon;
public WeatherValue feelsLikeTemperature;
public WeatherValue dewPoint;
public Integer relativeHumidity;
public Wind wind;
public String locationName;
public WeatherValue visibility;
public WeatherValue pressure;
public WeatherValue pressureChange;
public WeatherForecastCurrent(final WeatherSpec weatherSpec) {
epochSeconds = weatherSpec.timestamp;
temperature = new WeatherValue(weatherSpec.currentTemp - 273f, "CELSIUS");
description = weatherSpec.currentCondition;
icon = mapToCmfCondition(weatherSpec.currentConditionCode);
feelsLikeTemperature = new WeatherValue(weatherSpec.currentTemp - 273f, "CELSIUS");
dewPoint = new WeatherValue(weatherSpec.dewPoint - 273f, "CELSIUS");
relativeHumidity = weatherSpec.currentHumidity;
wind = new Wind(new WeatherValue(weatherSpec.windSpeed * 3.6, "METERS_PER_SECOND"), weatherSpec.windDirection);
locationName = weatherSpec.location;
visibility = new WeatherValue(weatherSpec.visibility, "METER");
pressure = new WeatherValue(weatherSpec.pressure * 0.02953, "INCHES_OF_MERCURY");
pressureChange = new WeatherValue(0f, "INCHES_OF_MERCURY");
}
}
public static class WeatherValue {
public Number value;
public String units;
public WeatherValue(final Number value, final String units) {
this.value = value;
this.units = units;
}
}
public static class Wind {
public WeatherValue speed;
public String directionString; // NW
public Integer direction;
public Wind(final WeatherValue speed, final int direction) {
this.speed = speed;
this.direction = direction;
}
}
public static int mapToCmfCondition(int openWeatherMapCondition) {
// Icons mapped from a Venu 3:
// 0 1 2 unk
// 3 4 5 6 sunny
// 7 8 9 10 sun cloudy
// 11 12 cloudy with dashes below
// 13 14 sun cloud 2 clouds
// 15 16 clouds
// 17 rain
// 18 19 20 21 rain with sun (or night at night?)
// 22 rain
// 23 24 unk
// 25 26 thunder with rain and sun behind
// 27 thunder with rain
// 28 29 rain
// 30 31 32 33 34 snow with clouds
// 35 36 37 snowflake
// 38 snow with clouds, with big flake
// 39 snow with rain
// 40 41 snow with rain
// 42 43 44 rain with snow
// 45 rain with snow
// 46 wind
// 47 48 foggy (dashes?)
// 49 50 51 unk
switch (openWeatherMapCondition) {
//Group 2xx: Thunderstorm
case 210: //light thunderstorm:: //11d
case 200: //thunderstorm with light rain: //11d
case 201: //thunderstorm with rain: //11d
case 202: //thunderstorm with heavy rain: //11d
case 230: //thunderstorm with light drizzle: //11d
case 231: //thunderstorm with drizzle: //11d
case 232: //thunderstorm with heavy drizzle: //11d
case 211: //thunderstorm: //11d
case 212: //heavy thunderstorm: //11d
case 221: //ragged thunderstorm: //11d
return 27;
//Group 90x: Extreme
case 901: //tropical storm
//Group 7xx: Atmosphere
case 781: //tornado: //[[file:50d.png]]
//Group 90x: Extreme
case 900: //tornado
// Group 7xx: Atmosphere
case 771: //squalls: //[[file:50d.png]]
//Group 9xx: Additional
case 960: //storm
case 961: //violent storm
case 902: //hurricane
case 962: //hurricane
return 46;
//Group 3xx: Drizzle
case 300: //light intensity drizzle: //09d
case 301: //drizzle: //09d
case 302: //heavy intensity drizzle: //09d
case 310: //light intensity drizzle rain: //09d
case 311: //drizzle rain: //09d
case 312: //heavy intensity drizzle rain: //09d
case 313: //shower rain and drizzle: //09d
case 314: //heavy shower rain and drizzle: //09d
case 321: //shower drizzle: //09d
//Group 5xx: Rain
case 500: //light rain: //10d
case 501: //moderate rain: //10d
case 502: //heavy intensity rain: //10d
case 503: //very heavy rain: //10d
case 504: //extreme rain: //10d
case 520: //light intensity shower rain: //09d
case 521: //shower rain: //09d
case 522: //heavy intensity shower rain: //09d
case 531: //ragged shower rain: //09d
return 17;
//Group 90x: Extreme
case 906: //hail
case 615: //light rain and snow: //[[file:13d.png]]
case 616: //rain and snow: //[[file:13d.png]]
case 511: //freezing rain: //13d
return 40;
//Group 6xx: Snow
case 611: //sleet: //[[file:13d.png]]
case 612: //shower sleet: //[[file:13d.png]]
//Group 6xx: Snow
case 600: //light snow: //[[file:13d.png]]
case 601: //snow: //[[file:13d.png]]
//Group 6xx: Snow
case 602: //heavy snow: //[[file:13d.png]]
//Group 6xx: Snow
case 620: //light shower snow: //[[file:13d.png]]
case 621: //shower snow: //[[file:13d.png]]
case 622: //heavy shower snow: //[[file:13d.png]]
return 38;
//Group 7xx: Atmosphere
case 701: //mist: //[[file:50d.png]]
case 711: //smoke: //[[file:50d.png]]
case 721: //haze: //[[file:50d.png]]
case 731: //sandcase dust whirls: //[[file:50d.png]]
case 741: //fog: //[[file:50d.png]]
case 751: //sand: //[[file:50d.png]]
case 761: //dust: //[[file:50d.png]]
case 762: //volcanic ash: //[[file:50d.png]]
return 47;
//Group 800: Clear
case 800: //clear sky: //[[file:01d.png]] [[file:01n.png]]
return 5;
//Group 90x: Extreme
case 904: //hot
return 5;
//Group 80x: Clouds
case 801: //few clouds: //[[file:02d.png]] [[file:02n.png]]
case 802: //scattered clouds: //[[file:03d.png]] [[file:03d.png]]
return 8;
case 803: //broken clouds: //[[file:04d.png]] [[file:03d.png]]
return 15;
//Group 80x: Clouds
case 804: //overcast clouds: //[[file:04d.png]] [[file:04d.png]]
return 15;
//Group 9xx: Additional
case 905: //windy
case 951: //calm
case 952: //light breeze
case 953: //gentle breeze
case 954: //moderate breeze
case 955: //fresh breeze
case 956: //strong breeze
case 957: //high windcase near gale
case 958: //gale
case 959: //severe gale
return 46;
default:
//Group 90x: Extreme
case 903: //cold
return 35;
}
}
}

View File

@ -0,0 +1,58 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminPreferences;
import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.GarminCapability;
public class ConfigurationMessage extends GFDIMessage {
public final Set<GarminCapability> OUR_CAPABILITIES = GarminCapability.ALL_CAPABILITIES;
private final byte[] incomingConfigurationPayload;
private final Set<GarminCapability> capabilities;
private final byte[] ourConfigurationPayload = GarminCapability.setToBinary(OUR_CAPABILITIES);
public ConfigurationMessage(GarminMessage garminMessage, byte[] configurationPayload) {
this.garminMessage = garminMessage;
if (configurationPayload.length > 255)
throw new IllegalArgumentException("Too long payload");
this.incomingConfigurationPayload = configurationPayload;
this.capabilities = GarminCapability.setFromBinary(configurationPayload);
LOG.info("Received configuration message; capabilities: {}", GarminCapability.setToString(capabilities));
this.statusMessage = this.getStatusMessage();
}
public static ConfigurationMessage parseIncoming(MessageReader reader, GarminMessage garminMessage) {
final int endOfPayload = reader.readByte();
ConfigurationMessage configurationMessage = new ConfigurationMessage(garminMessage, reader.readBytes(endOfPayload - reader.getPosition()));
return configurationMessage;
}
@Override
public List<GBDeviceEvent> getGBDeviceEvent() {
final Set<Object> capabilitiesPref = new HashSet<>();
for (final GarminCapability capability : capabilities) {
capabilitiesPref.add(capability.name());
}
return Collections.singletonList(
new GBDeviceEventUpdatePreferences(GarminPreferences.PREF_GARMIN_CAPABILITIES, capabilitiesPref)
);
}
@Override
protected boolean generateOutgoing() {
final MessageWriter writer = new MessageWriter(response);
writer.writeShort(0); // placeholder for packet size
writer.writeShort(garminMessage.getId());
writer.writeByte(ourConfigurationPayload.length);
writer.writeBytes(ourConfigurationPayload);
return true;
}
}

View File

@ -0,0 +1,62 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
import java.util.Random;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.FileType;
public class CreateFileMessage extends GFDIMessage {
private final int fileSize;
private final FileType.FILETYPE filetype;
private final boolean generateOutgoing;
public CreateFileMessage(GarminMessage garminMessage, int fileSize, FileType.FILETYPE filetype) {
this.fileSize = fileSize;
this.filetype = filetype;
this.garminMessage = garminMessage;
this.statusMessage = this.getStatusMessage();
this.generateOutgoing = false;
}
public CreateFileMessage(int fileSize, FileType.FILETYPE filetype) {
this.garminMessage = GarminMessage.CREATE_FILE;
this.fileSize = fileSize;
this.filetype = filetype;
this.statusMessage = this.getStatusMessage();
this.generateOutgoing = true;
}
public static CreateFileMessage parseIncoming(MessageReader reader, GarminMessage garminMessage) {
final int fileSize = reader.readInt();
final int dataType = reader.readByte(); //SupportedFileTypesStatusMessage.FileTypeInfo.type
final int subType = reader.readByte();//SupportedFileTypesStatusMessage.FileTypeInfo.subtypetype
final FileType.FILETYPE filetype = FileType.FILETYPE.fromDataTypeSubType(dataType, subType);
final int fileIndex = reader.readShort(); //???
reader.readByte(); //unk
final int subTypeMask = reader.readByte(); //???
final int numberMask = reader.readShort(); //???
return new CreateFileMessage(garminMessage, fileSize, filetype);
}
@Override
protected boolean generateOutgoing() { //TODO: adjust variables
Random random = new Random();
final MessageWriter writer = new MessageWriter(response);
writer.writeShort(0); // packet size will be filled below
writer.writeShort(this.garminMessage.getId());
writer.writeInt(this.fileSize);
writer.writeByte(this.filetype.getType());
writer.writeByte(this.filetype.getSubType());
writer.writeShort(0); //fileIndex
writer.writeByte(0); //reserved
writer.writeByte(0); //subtypemask
writer.writeShort(65535); //numbermask
writer.writeShort(0); ///???
writer.writeLong(random.nextLong());
return generateOutgoing;
}
}

View File

@ -0,0 +1,56 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
import org.threeten.bp.Instant;
import org.threeten.bp.ZoneId;
import org.threeten.bp.zone.ZoneOffsetTransition;
import org.threeten.bp.zone.ZoneRules;
import java.util.TimeZone;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminTimeUtils;
public class CurrentTimeRequestMessage extends GFDIMessage {
private final int referenceID;
public CurrentTimeRequestMessage(int referenceID, GarminMessage garminMessage) {
this.garminMessage = garminMessage;
this.referenceID = referenceID;
this.statusMessage = this.getStatusMessage();
}
public static CurrentTimeRequestMessage parseIncoming(MessageReader reader, GarminMessage garminMessage) {
final int referenceID = reader.readInt();
return new CurrentTimeRequestMessage(referenceID, garminMessage);
}
@Override
protected boolean generateOutgoing() {
final Instant now = Instant.now();
final ZoneRules zoneRules = ZoneId.systemDefault().getRules();
final int dstOffset = (int) zoneRules.getDaylightSavings(now).getSeconds();
final int timeZoneOffset = TimeZone.getDefault().getOffset(now.toEpochMilli()) / 1000;
final int garminTimestamp = GarminTimeUtils.unixTimeToGarminTimestamp((int) now.getEpochSecond());
final ZoneOffsetTransition nextTransitionStart = zoneRules.nextTransition(now);
final int nextTransitionStartsTs = (int) nextTransitionStart.toEpochSecond();
final int nextTransitionEndsTs = (int) zoneRules.nextTransition(nextTransitionStart.getInstant()).toEpochSecond();
LOG.info("Processing current time request #{}: time={}, DST={}, ofs={}", referenceID, garminTimestamp, dstOffset, timeZoneOffset);
final MessageWriter writer = new MessageWriter(response);
writer.writeShort(0); // packet size will be filled below
writer.writeShort(GarminMessage.RESPONSE.getId());
writer.writeShort(this.garminMessage.getId());
writer.writeByte(Status.ACK.ordinal());
writer.writeInt(referenceID);
writer.writeInt(garminTimestamp);
writer.writeInt(timeZoneOffset);
writer.writeInt(GarminTimeUtils.unixTimeToGarminTimestamp(nextTransitionEndsTs));
writer.writeInt(GarminTimeUtils.unixTimeToGarminTimestamp(nextTransitionStartsTs));
return true;
}
}

View File

@ -0,0 +1,113 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
import android.annotation.SuppressLint;
import android.bluetooth.BluetoothAdapter;
import android.os.Build;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo;
public class DeviceInformationMessage extends GFDIMessage {
final int ourUnitNumber = -1;
final int ourSoftwareVersion = 7791;
final int ourMaxPacketSize = -1;
private final int incomingProtocolVersion;
private final int ourProtocolVersion = 150;
private final int incomingProductNumber;
private final int ourProductNumber = -1;
private final String incomingUnitNumber;
private final int incomingSoftwareVersion;
private final int incomingMaxPacketSize;
private final String bluetoothFriendlyName;
private final String deviceName;
private final String deviceModel;
// dual-pairing flags & MAC addresses...
public DeviceInformationMessage(GarminMessage garminMessage, int protocolVersion, int productNumber, String unitNumber, int softwareVersion, int maxPacketSize, String bluetoothFriendlyName, String deviceName, String deviceModel) {
this.garminMessage = garminMessage;
this.incomingProtocolVersion = protocolVersion;
this.incomingProductNumber = productNumber;
this.incomingUnitNumber = unitNumber;
this.incomingSoftwareVersion = softwareVersion;
this.incomingMaxPacketSize = maxPacketSize;
this.bluetoothFriendlyName = bluetoothFriendlyName;
this.deviceName = deviceName;
this.deviceModel = deviceModel;
GFDIMessage.setMaxPacketSize(maxPacketSize);
this.statusMessage = getStatusMessage();
}
public static DeviceInformationMessage parseIncoming(MessageReader reader, GarminMessage garminMessage) {
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(garminMessage, protocolVersion, productNumber, unitNumber, softwareVersion, maxPacketSize, bluetoothFriendlyName, deviceName, deviceModel);
}
@SuppressLint("MissingPermission")
@Override
protected boolean generateOutgoing() {
final int protocolFlags = this.incomingProtocolVersion / 100 == 1 ? 1 : 0;
final MessageWriter writer = new MessageWriter(response);
writer.writeShort(0); // placeholder for packet size
writer.writeShort(GarminMessage.RESPONSE.getId());
writer.writeShort(this.garminMessage.getId());
writer.writeByte(Status.ACK.ordinal());
writer.writeShort(ourProtocolVersion);
writer.writeShort(ourProductNumber);
writer.writeInt(ourUnitNumber);
writer.writeShort(ourSoftwareVersion);
writer.writeShort(ourMaxPacketSize);
String bluetoothName;
try {
bluetoothName = BluetoothAdapter.getDefaultAdapter().getName();
} catch (final Exception e) {
LOG.error("Failed to get bluetooth name", e);
bluetoothName = "Unknown";
}
writer.writeString(bluetoothName);
writer.writeString(Build.MANUFACTURER);
writer.writeString(Build.DEVICE);
writer.writeByte(protocolFlags);
return true;
}
@Override
public List<GBDeviceEvent> getGBDeviceEvent() {
LOG.info(
"Received device information: protocol {}, product {}, unit {}, SW {}, max packet {}, BT name {}, device name {}, device model {}",
incomingProtocolVersion,
incomingProductNumber,
incomingUnitNumber,
getSoftwareVersionStr(),
incomingMaxPacketSize,
bluetoothFriendlyName,
deviceName,
deviceModel
);
GBDeviceEventVersionInfo versionCmd = new GBDeviceEventVersionInfo();
versionCmd.fwVersion = getSoftwareVersionStr();
versionCmd.hwVersion = deviceModel;
return Collections.singletonList(versionCmd);
}
private String getSoftwareVersionStr() {
int softwareVersionMajor = incomingSoftwareVersion / 100;
int softwareVersionMinor = incomingSoftwareVersion % 100;
return String.format(Locale.ROOT, "%d.%02d", softwareVersionMajor, softwareVersionMinor);
}
}

View File

@ -0,0 +1,45 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
public class DownloadRequestMessage extends GFDIMessage {
private final int fileIndex;
private final REQUEST_TYPE requestType;
private final int crcSeed;
private final int dataSize;
private final int dataOffset;
public DownloadRequestMessage(GarminMessage garminMessage, int fileIndex, int size, REQUEST_TYPE requestType, int crcSeed, int dataSize, int dataOffset) {
this.requestType = requestType;
this.crcSeed = crcSeed;
this.dataSize = dataSize;
this.dataOffset = dataOffset;
this.garminMessage = garminMessage;
this.fileIndex = fileIndex;
this.statusMessage = this.getStatusMessage();
}
public DownloadRequestMessage(int fileIndex, int dataSize, REQUEST_TYPE requestType, int crcSeed, int dataOffset) {
this(GarminMessage.DOWNLOAD_REQUEST, fileIndex, dataSize, requestType, crcSeed, dataSize, dataOffset);
}
@Override
protected boolean generateOutgoing() {
final MessageWriter writer = new MessageWriter(response);
writer.writeShort(0); // packet size will be filled below
writer.writeShort(this.garminMessage.getId());
writer.writeShort(this.fileIndex);
writer.writeInt(this.dataOffset);
writer.writeByte(this.requestType.ordinal());
writer.writeShort(this.crcSeed);
writer.writeInt(this.dataSize);
return true;
}
public enum REQUEST_TYPE {
CONTINUE,
NEW,
}
}

View File

@ -0,0 +1,60 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.FileTransferDataStatusMessage;
public class FileTransferDataMessage extends GFDIMessage {
private final byte[] message;
private final int dataOffset;
private final boolean sendOutgoing;
private final int crc;
public FileTransferDataMessage(byte[] message, int dataOffset, int crc) {
this(message, dataOffset, crc, true);
}
public FileTransferDataMessage(byte[] message, int dataOffset, int crc, boolean sendOutgoing) {
this.garminMessage = GarminMessage.FILE_TRANSFER_DATA;
this.dataOffset = dataOffset;
this.crc = crc;
this.message = message;
this.statusMessage = new FileTransferDataStatusMessage(GarminMessage.FILE_TRANSFER_DATA, Status.ACK, FileTransferDataStatusMessage.TransferStatus.OK, dataOffset);
this.sendOutgoing = sendOutgoing;
}
public static FileTransferDataMessage parseIncoming(MessageReader reader, GarminMessage garminMessage) {
final int flags = reader.readByte();
final int crc = reader.readShort();
final int dataOffset = reader.readInt();
final byte[] message = reader.readBytes(reader.remaining());
return new FileTransferDataMessage(message, dataOffset, crc, false);
}
public byte[] getMessage() {
return message;
}
public int getCrc() {
return crc;
}
public int getDataOffset() {
return dataOffset;
}
@Override
protected boolean generateOutgoing() {
final MessageWriter writer = new MessageWriter(response);
writer.writeShort(0); // packet size will be filled below
writer.writeShort(garminMessage.getId());
writer.writeByte(0); //flags?
writer.writeShort(crc);
writer.writeInt(dataOffset);
writer.writeBytes(message);
return sendOutgoing;
}
}

View File

@ -0,0 +1,32 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
import java.util.Collections;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventFindPhone;
public class FindMyPhoneCancelMessage extends GFDIMessage {
public FindMyPhoneCancelMessage(GarminMessage garminMessage) {
this.garminMessage = garminMessage;
this.statusMessage = getStatusMessage();
}
public static FindMyPhoneCancelMessage parseIncoming(MessageReader reader, GarminMessage garminMessage) {
return new FindMyPhoneCancelMessage(garminMessage);
}
@Override
public List<GBDeviceEvent> getGBDeviceEvent() {
final GBDeviceEventFindPhone findPhoneEvent = new GBDeviceEventFindPhone();
findPhoneEvent.event = GBDeviceEventFindPhone.Event.STOP;
return Collections.singletonList(findPhoneEvent);
}
@Override
protected boolean generateOutgoing() {
return false;
}
}

View File

@ -0,0 +1,36 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
import java.util.Collections;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventFindPhone;
public class FindMyPhoneRequestMessage extends GFDIMessage {
private final int duration;
public FindMyPhoneRequestMessage(GarminMessage garminMessage, int duration) {
this.garminMessage = garminMessage;
this.duration = duration;
this.statusMessage = getStatusMessage();
}
public static FindMyPhoneRequestMessage parseIncoming(MessageReader reader, GarminMessage garminMessage) {
final int duration = reader.readByte();
return new FindMyPhoneRequestMessage(garminMessage, duration);
}
@Override
public List<GBDeviceEvent> getGBDeviceEvent() {
final GBDeviceEventFindPhone findPhoneEvent = new GBDeviceEventFindPhone();
findPhoneEvent.event = GBDeviceEventFindPhone.Event.START;
return Collections.singletonList(findPhoneEvent);
}
@Override
protected boolean generateOutgoing() {
return false;
}
}

View File

@ -0,0 +1,58 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
import java.util.ArrayList;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.PredefinedLocalMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordData;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordHeader;
public class FitDataMessage extends GFDIMessage {
private final List<RecordData> recordDataList;
public FitDataMessage(List<RecordData> recordDataList, GarminMessage garminMessage) {
this.recordDataList = recordDataList;
this.garminMessage = garminMessage;
}
public FitDataMessage(List<RecordData> recordDataList) {
this.recordDataList = recordDataList;
this.garminMessage = GarminMessage.FIT_DATA;
}
public static FitDataMessage parseIncoming(MessageReader reader, GarminMessage garminMessage) {
final List<RecordData> recordDataList = new ArrayList<>();
while (reader.remaining() > 0) {
RecordHeader recordHeader = new RecordHeader((byte) reader.readByte(), true);
if (recordHeader.isDefinition())
return null;
PredefinedLocalMessage predefinedLocalMessage = recordHeader.getPredefinedLocalMessage();
if (predefinedLocalMessage == null) {
LOG.warn("Local message is null");
return null;
}
RecordData recordData = new RecordData(predefinedLocalMessage.getRecordDefinition());
recordData.parseDataMessage(reader);
recordDataList.add(recordData);
}
return new FitDataMessage(recordDataList, garminMessage);
}
public List<RecordData> getRecordDataList() {
return recordDataList;
}
@Override
protected boolean generateOutgoing() {
final MessageWriter writer = new MessageWriter(response);
writer.writeShort(0); // packet size will be filled below
writer.writeShort(this.garminMessage.getId());
for (RecordData recordData : recordDataList) {
recordData.generateOutgoingDataPayload(writer);
}
return true;
}
}

View File

@ -0,0 +1,50 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
import java.util.ArrayList;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordDefinition;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordHeader;
public class FitDefinitionMessage extends GFDIMessage {
private final List<RecordDefinition> recordDefinitions;
public FitDefinitionMessage(List<RecordDefinition> recordDefinitions, GarminMessage garminMessage) {
this.recordDefinitions = recordDefinitions;
this.garminMessage = garminMessage;
this.statusMessage = this.getStatusMessage();
}
public FitDefinitionMessage(List<RecordDefinition> recordDefinitions) {
this.recordDefinitions = recordDefinitions;
this.garminMessage = GarminMessage.FIT_DEFINITION;
}
public static FitDefinitionMessage parseIncoming(MessageReader reader, GarminMessage garminMessage) {
List<RecordDefinition> recordDefinitions = new ArrayList<>();
while (reader.remaining() > 0) {
RecordHeader recordHeader = new RecordHeader((byte) reader.readByte(), true);
recordDefinitions.add(RecordDefinition.parseIncoming(reader, recordHeader));
}
return new FitDefinitionMessage(recordDefinitions, garminMessage);
}
public List<RecordDefinition> getRecordDefinitions() {
return recordDefinitions;
}
@Override
protected boolean generateOutgoing() {
final MessageWriter writer = new MessageWriter(response);
writer.writeShort(0); // packet size will be filled below
writer.writeShort(garminMessage.getId());
for (RecordDefinition recordDefinition : recordDefinitions) {
recordDefinition.generateOutgoingPayload(writer);
}
return true;
}
}

View File

@ -0,0 +1,230 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
import androidx.annotation.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.reflect.Method;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Collections;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.ChecksumCalculator;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminByteBufferReader;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.GFDIStatusMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.GenericStatusMessage;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
public abstract class GFDIMessage {
public static final int MESSAGE_REQUEST = 5001;
public static final int MESSAGE_DIRECTORY_FILE_FILTER_REQUEST = 5007;
public static final int MESSAGE_FILE_READY = 5009;
public static final int MESSAGE_BATTERY_STATUS = 5023;
public static final int MESSAGE_NOTIFICATION_SOURCE = 5033;
public static final int MESSAGE_GNCS_CONTROL_POINT_REQUEST = 5034;
public static final int MESSAGE_GNCS_DATA_SOURCE = 5035;
public static final int MESSAGE_NOTIFICATION_SERVICE_SUBSCRIPTION = 5036;
public static final int MESSAGE_SYNC_REQUEST = 5037;
public static final int MESSAGE_AUTH_NEGOTIATION = 5101;
protected static final Logger LOG = LoggerFactory.getLogger(GFDIMessage.class);
private static int maxPacketSize = 375; //safe default?
protected final ByteBuffer response = ByteBuffer.allocate(1000);
protected GFDIStatusMessage statusMessage;
protected GarminMessage garminMessage;
public static int getMaxPacketSize() {
return maxPacketSize;
}
public static void setMaxPacketSize(int maxPacketSize) {
GFDIMessage.maxPacketSize = maxPacketSize;
}
public static GFDIMessage parseIncoming(byte[] message) {
final MessageReader messageReader = new MessageReader(message);
final int messageType = messageReader.readShort();
try {
final GarminMessage garminMessage = GarminMessage.fromId(messageType);
if (garminMessage == null) {
LOG.warn("Unknown message type {}, message {}", messageType, message);
return new UnhandledMessage(messageType);
}
final Method m = garminMessage.objectClass.getMethod("parseIncoming", MessageReader.class, GarminMessage.class);
return garminMessage.objectClass.cast(m.invoke(null, messageReader, garminMessage));
} catch (final Exception e) {
LOG.error("UNHANDLED GFDI MESSAGE TYPE {}, MESSAGE {}", messageType, message, e);
return new UnhandledMessage(messageType);
} finally {
messageReader.warnIfLeftover(messageType);
}
}
protected abstract boolean generateOutgoing();
public byte[] getOutgoingMessage() {
response.clear();
boolean toSend = generateOutgoing();
response.order(ByteOrder.LITTLE_ENDIAN);
if (!toSend)
return null;
addLengthAndChecksum();
response.flip();
final byte[] packet = new byte[response.limit()];
response.get(packet);
return packet;
}
protected GFDIStatusMessage getStatusMessage() {
return new GenericStatusMessage(garminMessage, Status.ACK);
}
public List<GBDeviceEvent> getGBDeviceEvent() {
return Collections.emptyList();
}
public byte[] getAckBytestream() {
if (null == this.statusMessage) {
return null;
}
return this.statusMessage.getOutgoingMessage();
}
private void addLengthAndChecksum() {
response.putShort(0, (short) (response.position() + 2));
response.putShort((short) ChecksumCalculator.computeCrc(response.asReadOnlyBuffer(), 0, response.position()));
}
public enum GarminMessage {
RESPONSE(5000, GFDIStatusMessage.class), //TODO: STATUS is a better name?
DOWNLOAD_REQUEST(5002, DownloadRequestMessage.class),
UPLOAD_REQUEST(5003, UploadRequestMessage.class),
FILE_TRANSFER_DATA(5004, FileTransferDataMessage.class),
CREATE_FILE(5005, CreateFileMessage.class),
SET_FILE_FLAG(5008, SetFileFlagsMessage.class),
FIT_DEFINITION(5011, FitDefinitionMessage.class),
FIT_DATA(5012, FitDataMessage.class),
WEATHER_REQUEST(5014, WeatherMessage.class),
DEVICE_INFORMATION(5024, DeviceInformationMessage.class),
DEVICE_SETTINGS(5026, SetDeviceSettingsMessage.class),
SYSTEM_EVENT(5030, SystemEventMessage.class),
SUPPORTED_FILE_TYPES_REQUEST(5031, SupportedFileTypesMessage.class),
NOTIFICATION_UPDATE(5033, NotificationUpdateMessage.class),
NOTIFICATION_CONTROL(5034, NotificationControlMessage.class),
NOTIFICATION_DATA(5035, NotificationDataMessage.class),
NOTIFICATION_SUBSCRIPTION(5036, NotificationSubscriptionMessage.class),
FIND_MY_PHONE_REQUEST(5039, FindMyPhoneRequestMessage.class),
FIND_MY_PHONE_CANCEL(5040, FindMyPhoneCancelMessage.class),
MUSIC_CONTROL(5041, MusicControlMessage.class),
MUSIC_CONTROL_CAPABILITIES(5042, MusicControlCapabilitiesMessage.class),
PROTOBUF_REQUEST(5043, ProtobufMessage.class),
PROTOBUF_RESPONSE(5044, ProtobufMessage.class),
MUSIC_CONTROL_ENTITY_UPDATE(5049, MusicControlEntityUpdateMessage.class),
CONFIGURATION(5050, ConfigurationMessage.class),
CURRENT_TIME_REQUEST(5052, CurrentTimeRequestMessage.class),
;
private final Class<? extends GFDIMessage> objectClass;
private final int id;
GarminMessage(int id, Class<? extends GFDIMessage> objectClass) {
this.id = id;
this.objectClass = objectClass;
}
public static Class<? extends GFDIMessage> getClassFromId(final int id) {
for (final GarminMessage garminMessage : GarminMessage.values()) {
if (garminMessage.getId() == id) {
return garminMessage.getObjectClass();
}
}
return null;
}
@Nullable
public static GarminMessage fromId(final int id) {
for (final GarminMessage garminMessage : GarminMessage.values()) {
if (garminMessage.getId() == id) {
return garminMessage;
}
}
return null;
}
public int getId() {
return id;
}
private Class<? extends GFDIMessage> getObjectClass() {
return objectClass;
}
}
public enum Status {
ACK,
NAK,
UNSUPPORTED,
DECODE_ERROR,
CRC_ERROR,
LENGTH_ERROR;
public static Status fromCode(final int code) {
for (final Status status : Status.values()) {
if (status.ordinal() == code) {
return status;
}
}
throw new IllegalArgumentException("Unknown status code " + code);
}
}
public static class MessageReader extends GarminByteBufferReader {
private final int payloadSize;
public MessageReader(byte[] data) {
super(data);
this.byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
this.payloadSize = readShort(); //includes CRC
checkSize();
checkCRC();
this.byteBuffer.limit(payloadSize - 2); //remove CRC
}
public void skip(int offset) {
if (remaining() < offset) throw new IllegalStateException();
byteBuffer.position(byteBuffer.position() + offset);
}
private void checkSize() {
if (payloadSize != byteBuffer.capacity()) {
LOG.error("Received GFDI packet with invalid length: {} vs {}", payloadSize, byteBuffer.capacity());
throw new IllegalArgumentException("Received GFDI packet with invalid length");
}
}
private void checkCRC() {
final int crc = Short.toUnsignedInt(byteBuffer.getShort(payloadSize - 2));
final int correctCrc = ChecksumCalculator.computeCrc(byteBuffer.asReadOnlyBuffer(), 0, payloadSize - 2);
if (crc != correctCrc) {
LOG.error("Received GFDI packet with invalid CRC: {} vs {}", crc, correctCrc);
throw new IllegalArgumentException("Received GFDI packet with invalid CRC");
}
}
public void warnIfLeftover(int messageType) {
if (byteBuffer.hasRemaining() && byteBuffer.position() < (byteBuffer.limit())) {
int pos = byteBuffer.position();
int numBytes = (byteBuffer.limit()) - byteBuffer.position();
byte[] leftover = new byte[numBytes];
byteBuffer.get(leftover);
byteBuffer.position(pos);
LOG.warn("Leftover bytes when parsing message type {}. Bytes: {}, complete message: {}", messageType, GB.hexdump(leftover), GB.hexdump(byteBuffer.array()));
}
}
}
}

View File

@ -0,0 +1,87 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
public class MessageWriter {
private static final int DEFAULT_BUFFER_SIZE = 16384;
private final ByteBuffer byteBuffer;
public MessageWriter() {
this(DEFAULT_BUFFER_SIZE);
}
public MessageWriter(int bufferSize) {
this.byteBuffer = ByteBuffer.allocate(bufferSize);
this.byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
}
public MessageWriter(ByteBuffer byteBuffer) {
this.byteBuffer = byteBuffer;
this.byteBuffer.clear();
this.byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
}
public void setByteOrder(ByteOrder byteOrder) {
this.byteBuffer.order(byteOrder);
}
public void writeByte(int value) {
byteBuffer.put((byte) value);
}
public void writeShort(int value) {
byteBuffer.putShort((short) value);
}
public void writeInt(int value) {
byteBuffer.putInt(value);
}
public void writeLong(long value) {
byteBuffer.putLong(value);
}
public void writeFloat32(float value) {
byteBuffer.putFloat(value);
}
public void writeFloat64(double value) {
byteBuffer.putDouble(value);
}
public void writeString(String value) {
final byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
final int size = bytes.length;
if (size > 255) throw new IllegalArgumentException("Too long string");
if (byteBuffer.position() + 1 + size > byteBuffer.capacity())
throw new IllegalStateException();
byteBuffer.put((byte) size);
byteBuffer.put(bytes);
}
public byte[] getBytes() {
//TODO: implement the correct flip()/compat() logic
return byteBuffer.hasRemaining() ? Arrays.copyOf(byteBuffer.array(), byteBuffer.position()) : byteBuffer.array();
}
public byte[] peekBytes() {
return byteBuffer.array();
}
public int getSize() {
return byteBuffer.position();
}
public void writeBytes(byte[] bytes) {
writeBytes(bytes, 0, bytes.length);
}
public void writeBytes(byte[] bytes, int offset, int size) {
if (byteBuffer.position() + size > byteBuffer.capacity()) throw new IllegalStateException();
byteBuffer.put(Arrays.copyOfRange(bytes, offset, offset + size));
}
}

View File

@ -0,0 +1,48 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
public class MusicControlCapabilitiesMessage extends GFDIMessage {
private final int supportedCapabilities;
private final GarminMusicControlCommand[] commands = GarminMusicControlCommand.values();
public MusicControlCapabilitiesMessage(GarminMessage garminMessage, int supportedCapabilities) {
this.garminMessage = garminMessage;
this.supportedCapabilities = supportedCapabilities;
this.statusMessage = this.getStatusMessage();
}
public static MusicControlCapabilitiesMessage parseIncoming(MessageReader reader, GarminMessage garminMessage) {
final int supportedCapabilities = reader.readByte();
return new MusicControlCapabilitiesMessage(garminMessage, supportedCapabilities);
}
@Override
protected boolean generateOutgoing() {
if (commands.length > 255)
throw new IllegalArgumentException("Too many supported commands");
final MessageWriter writer = new MessageWriter(response);
writer.writeShort(0); // packet size will be filled below
writer.writeShort(GarminMessage.RESPONSE.getId());
writer.writeShort(this.garminMessage.getId());
writer.writeByte(Status.ACK.ordinal());
writer.writeByte(commands.length);
for (GarminMusicControlCommand command : commands) {
writer.writeByte(command.ordinal());
}
return true;
}
public enum GarminMusicControlCommand {
TOGGLE_PLAY_PAUSE,
SKIP_TO_NEXT_ITEM,
SKIP_TO_PREVIOUS_ITEM,
VOLUME_UP,
VOLUME_DOWN,
PLAY,
PAUSE,
SKIP_FORWARD,
SKIP_BACKWARDS
}
}

View File

@ -0,0 +1,81 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
import java.nio.charset.StandardCharsets;
import java.util.Map;
public class MusicControlEntityUpdateMessage extends GFDIMessage {
private final Map<MusicEntity, String> attributes;
public MusicControlEntityUpdateMessage(Map<MusicEntity, String> attributes) {
this.attributes = attributes;
this.garminMessage = GarminMessage.MUSIC_CONTROL_ENTITY_UPDATE;
}
@Override
protected boolean generateOutgoing() {
final MessageWriter writer = new MessageWriter(response);
writer.writeShort(0); // packet size will be filled below
writer.writeShort(this.garminMessage.getId());
for (Map.Entry<MusicEntity, String> entry : attributes.entrySet()) {
MusicEntity a = entry.getKey();
String value = entry.getValue();
if (null == value)
value = "";
byte[] v = value.getBytes(StandardCharsets.UTF_8);
if (v.length > 252) throw new IllegalArgumentException("Too long value");
writer.writeByte((v.length + 3) & 0xff); //the three following bytes
writer.writeByte(a.getEntityId());
writer.writeByte(a.ordinal());
writer.writeByte(0);//TODO what is this?
writer.writeBytes(v);
}
return true;
}
public enum PLAYER implements MusicEntity {
NAME,
PLAYBACK_INFO,
VOLUME;
@Override
public int getEntityId() {
return 0;
}
}
public enum QUEUE implements MusicEntity {
INDEX,
COUNT,
SHUFFLE,
REPEAT;
@Override
public int getEntityId() {
return 1;
}
}
public enum TRACK implements MusicEntity {
ARTIST,
ALBUM,
TITLE,
DURATION;
@Override
public int getEntityId() {
return 2;
}
}
public interface MusicEntity {
int getEntityId();
int ordinal();
}
}

View File

@ -0,0 +1,53 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
import java.util.Collections;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventMusicControl;
public class MusicControlMessage extends GFDIMessage {
private static final MusicControlCapabilitiesMessage.GarminMusicControlCommand[] commands = MusicControlCapabilitiesMessage.GarminMusicControlCommand.values();
private final GBDeviceEventMusicControl event;
public MusicControlMessage(GarminMessage garminMessage, MusicControlCapabilitiesMessage.GarminMusicControlCommand command) {
this.event = new GBDeviceEventMusicControl();
this.garminMessage = garminMessage;
switch (command) {
case TOGGLE_PLAY_PAUSE:
event.event = GBDeviceEventMusicControl.Event.PLAYPAUSE;
break;
case SKIP_TO_NEXT_ITEM:
event.event = GBDeviceEventMusicControl.Event.NEXT;
break;
case SKIP_TO_PREVIOUS_ITEM:
event.event = GBDeviceEventMusicControl.Event.PREVIOUS;
break;
case VOLUME_UP:
event.event = GBDeviceEventMusicControl.Event.VOLUMEUP;
break;
case VOLUME_DOWN:
event.event = GBDeviceEventMusicControl.Event.VOLUMEDOWN;
break;
}
this.statusMessage = this.getStatusMessage();
}
public static MusicControlMessage parseIncoming(MessageReader reader, GarminMessage garminMessage) {
MusicControlCapabilitiesMessage.GarminMusicControlCommand command = commands[reader.readByte()];
return new MusicControlMessage(garminMessage, command);
}
public List<GBDeviceEvent> getGBDeviceEvent() {
return Collections.singletonList(event);
}
@Override
protected boolean generateOutgoing() {
return false;
}
}

View File

@ -0,0 +1,145 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.NotificationsHandler;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.NotificationControlStatusMessage;
import static nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.NotificationsHandler.NotificationCommand.GET_NOTIFICATION_ATTRIBUTES;
import static nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.NotificationsHandler.NotificationCommand.PERFORM_LEGACY_NOTIFICATION_ACTION;
import static nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.NotificationsHandler.NotificationCommand.PERFORM_NOTIFICATION_ACTION;
public class NotificationControlMessage extends GFDIMessage {
private final NotificationsHandler.NotificationCommand command;
private final int notificationId;
private Map<NotificationsHandler.NotificationAttribute, Integer> notificationAttributesMap;
private NotificationsHandler.LegacyNotificationAction legacyNotificationAction;
private NotificationsHandler.NotificationAction notificationAction;
private String actionString;
private GBDeviceEvent deviceEvent;
public NotificationControlMessage(GarminMessage garminMessage, NotificationsHandler.NotificationCommand command, int notificationId, NotificationsHandler.NotificationAction notificationAction, String actionString) {
this.garminMessage = garminMessage;
this.command = command;
this.notificationId = notificationId;
this.notificationAction = notificationAction;
this.actionString = actionString;
this.statusMessage = new NotificationControlStatusMessage(garminMessage, GFDIMessage.Status.ACK, NotificationControlStatusMessage.NotificationChunkStatus.OK, NotificationControlStatusMessage.NotificationStatusCode.NO_ERROR);
}
public NotificationControlMessage(GarminMessage garminMessage, NotificationsHandler.NotificationCommand command, int notificationId, NotificationsHandler.LegacyNotificationAction legacyNotificationAction) {
this.garminMessage = garminMessage;
this.command = command;
this.notificationId = notificationId;
this.legacyNotificationAction = legacyNotificationAction;
this.statusMessage = new NotificationControlStatusMessage(garminMessage, GFDIMessage.Status.ACK, NotificationControlStatusMessage.NotificationChunkStatus.OK, NotificationControlStatusMessage.NotificationStatusCode.NO_ERROR);
}
public NotificationControlMessage(GarminMessage garminMessage, NotificationsHandler.NotificationCommand command, int notificationId, Map<NotificationsHandler.NotificationAttribute, Integer> notificationAttributesMap) {
this.garminMessage = garminMessage;
this.command = command;
this.notificationId = notificationId;
this.notificationAttributesMap = notificationAttributesMap;
this.statusMessage = new NotificationControlStatusMessage(garminMessage, GFDIMessage.Status.ACK, NotificationControlStatusMessage.NotificationChunkStatus.OK, NotificationControlStatusMessage.NotificationStatusCode.NO_ERROR);
}
//TODO: the fact that we return three versions of this object is really ugly
public static NotificationControlMessage parseIncoming(MessageReader reader, GarminMessage garminMessage) {
final NotificationsHandler.NotificationCommand command = NotificationsHandler.NotificationCommand.fromCode(reader.readByte());
final int notificationId = reader.readInt();
if (command == GET_NOTIFICATION_ATTRIBUTES) {
final Map<NotificationsHandler.NotificationAttribute, Integer> notificationAttributesMap = createGetNotificationAttributesCommand(reader);
return new NotificationControlMessage(garminMessage, command, notificationId, notificationAttributesMap);
} else if (command == PERFORM_LEGACY_NOTIFICATION_ACTION) {
NotificationsHandler.LegacyNotificationAction[] values = NotificationsHandler.LegacyNotificationAction.values();
final NotificationsHandler.LegacyNotificationAction legacyNotificationAction = values[reader.readByte()];
return new NotificationControlMessage(garminMessage, command, notificationId, legacyNotificationAction);
} else if (command == PERFORM_NOTIFICATION_ACTION) {
final int actionId = reader.readByte();
final NotificationsHandler.NotificationAction notificationAction = NotificationsHandler.NotificationAction.fromCode(actionId);
final String actionString = reader.readNullTerminatedString();
return new NotificationControlMessage(garminMessage, command, notificationId, notificationAction, actionString);
}
LOG.warn("Unknown NotificationCommand in NotificationControlMessage");
return null;
}
private static Map<NotificationsHandler.NotificationAttribute, Integer> createGetNotificationAttributesCommand(MessageReader reader) {
final Map<NotificationsHandler.NotificationAttribute, Integer> notificationAttributesMap = new HashMap<>();
while (reader.remaining() > 0) {
final int attributeID = reader.readByte();
final NotificationsHandler.NotificationAttribute attribute = NotificationsHandler.NotificationAttribute.getByCode(attributeID);
LOG.info("Requested attribute: {}", attribute);
if (attribute == null) {
LOG.error("Unknown notification attribute {}", attributeID);
return null;
}
final int maxLength;
if (attribute.hasLengthParam) {
maxLength = reader.readShort();
} else if (attribute.hasAdditionalParams) {
maxLength = reader.readShort(); //TODO this is wrong
// TODO: What is this??
reader.readByte();
} else {
maxLength = 0;
}
notificationAttributesMap.put(attribute, maxLength);
}
return notificationAttributesMap;
}
public String getActionString() {
return actionString;
}
public void setDeviceEvent(GBDeviceEvent deviceEvent) {
this.deviceEvent = deviceEvent;
}
@Override
public List<GBDeviceEvent> getGBDeviceEvent() {
return Collections.singletonList(deviceEvent);
}
public NotificationsHandler.LegacyNotificationAction getLegacyNotificationAction() {
return legacyNotificationAction;
}
public NotificationsHandler.NotificationAction getNotificationAction() {
return notificationAction;
}
public NotificationsHandler.NotificationCommand getCommand() {
return command;
}
public int getNotificationId() {
return notificationId;
}
public Map<NotificationsHandler.NotificationAttribute, Integer> getNotificationAttributesMap() {
return notificationAttributesMap;
}
@Override
protected boolean generateOutgoing() {
return false;
}
}

View File

@ -0,0 +1,39 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
public class NotificationDataMessage extends GFDIMessage {
private final byte[] chunk;
private final int dataOffset;
private final int messageSize;
private final boolean sendOutgoing;
private final int crc;
public NotificationDataMessage(byte[] chunk, int messageSize, int dataOffset, int crc) {
this(chunk, messageSize, dataOffset, crc, true);
}
public NotificationDataMessage(byte[] chunk, int messageSize, int dataOffset, int crc, boolean sendOutgoing) {
this.garminMessage = GarminMessage.NOTIFICATION_DATA;
this.dataOffset = dataOffset;
this.crc = crc;
this.chunk = chunk;
this.messageSize = messageSize;
this.sendOutgoing = sendOutgoing;
}
public int getCrc() {
return crc;
}
@Override
protected boolean generateOutgoing() {
final MessageWriter writer = new MessageWriter(response);
writer.writeShort(0); // packet size will be filled below
writer.writeShort(garminMessage.getId());
writer.writeShort(messageSize);
writer.writeShort(crc);
writer.writeShort(dataOffset);
writer.writeBytes(chunk);
return sendOutgoing;
}
}

View File

@ -0,0 +1,41 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
import java.util.Collections;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents.NotificationSubscriptionDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.NotificationSubscriptionStatusMessage;
public class NotificationSubscriptionMessage extends GFDIMessage {
private final boolean enable;
private final int unk;
public NotificationSubscriptionMessage(GarminMessage garminMessage, boolean enable, int unk) {
this.garminMessage = garminMessage;
this.enable = enable;
this.unk = unk;
this.statusMessage = new NotificationSubscriptionStatusMessage(Status.ACK, NotificationSubscriptionStatusMessage.NotificationStatus.OK, enable, unk);
}
public static NotificationSubscriptionMessage parseIncoming(MessageReader reader, GarminMessage garminMessage) {
final boolean enable = reader.readByte() == 1;
final int unk = reader.readByte();
return new NotificationSubscriptionMessage(garminMessage, enable, unk);
}
@Override
public List<GBDeviceEvent> getGBDeviceEvent() {
NotificationSubscriptionDeviceEvent notificationSubscriptionDeviceEvent = new NotificationSubscriptionDeviceEvent();
notificationSubscriptionDeviceEvent.enable = this.enable;
return Collections.singletonList(notificationSubscriptionDeviceEvent);
}
@Override
protected boolean generateOutgoing() {
return false;
}
}

View File

@ -0,0 +1,115 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
import org.apache.commons.lang3.EnumUtils;
import java.util.EnumSet;
import nodomain.freeyourgadget.gadgetbridge.model.NotificationType;
public class NotificationUpdateMessage extends GFDIMessage {
final private NotificationUpdateType notificationUpdateType;
final private NotificationType notificationType;
final private int count; //how many notifications of the same type are present
final private int notificationId;
final private boolean hasActions;
final private boolean useLegacyActions = false;
public NotificationUpdateMessage(NotificationUpdateType notificationUpdateType, NotificationType notificationType, int count, int notificationId, boolean hasActions) {
this.garminMessage = GarminMessage.NOTIFICATION_UPDATE;
this.notificationUpdateType = notificationUpdateType;
this.notificationType = notificationType;
this.count = count;
this.notificationId = notificationId;
this.hasActions = hasActions;
}
@Override
protected boolean generateOutgoing() {
final MessageWriter writer = new MessageWriter(response);
writer.writeShort(0); // packet size will be filled below
writer.writeShort(this.garminMessage.getId());
writer.writeByte(this.notificationUpdateType.ordinal());
writer.writeByte(getCategoryFlags(this.notificationType));
writer.writeByte(getCategoryValue(this.notificationType));
writer.writeByte(this.count);
writer.writeInt(this.notificationId);
writer.writeByte(this.useLegacyActions ? 0x00 : 0x03);
return true;
}
private int getCategoryFlags(NotificationType notificationType) {
EnumSet<NotificationFlag> flags = EnumSet.noneOf(NotificationFlag.class);
if (this.hasActions && this.useLegacyActions) { //only needed for legacy actions
flags.add(NotificationFlag.ACTION_ACCEPT);
flags.add(NotificationFlag.ACTION_DECLINE);
}
switch (notificationType.getGenericType()) {
case "generic_phone":
case "generic_email":
case "generic_sms":
case "generic_chat":
flags.add(NotificationFlag.FOREGROUND);
break;
case "generic_navigation":
case "generic_social":
case "generic_alarm_clock":
case "generic":
flags.add(NotificationFlag.BACKGROUND);
}
return (int) EnumUtils.generateBitVector(NotificationFlag.class, flags);
}
private int getCategoryValue(NotificationType notificationType) {
switch (notificationType.getGenericType()) {
case "generic_phone":
return NotificationCategory.INCOMING_CALL.ordinal();
case "generic_email":
return NotificationCategory.EMAIL.ordinal();
case "generic_sms":
case "generic_chat":
return NotificationCategory.SMS.ordinal();
case "generic_navigation":
return NotificationCategory.LOCATION.ordinal();
case "generic_social":
return NotificationCategory.SOCIAL.ordinal();
case "generic_alarm_clock":
case "generic":
return NotificationCategory.OTHER.ordinal();
}
return NotificationCategory.OTHER.ordinal();
}
public enum NotificationUpdateType {
ADD,
MODIFY,
REMOVE,
}
enum NotificationFlag { //was AncsEventFlag
BACKGROUND,
FOREGROUND,
UNK,
ACTION_ACCEPT, //only needed for legacy actions
ACTION_DECLINE, //only needed for legacy actions
}
enum NotificationCategory { //was AncsCategory
OTHER,
INCOMING_CALL,
MISSED_CALL,
VOICEMAIL,
SOCIAL,
SCHEDULE,
EMAIL,
NEWS,
HEALTH_AND_FITNESS,
BUSINESS_AND_FINANCE,
LOCATION,
ENTERTAINMENT,
SMS
}
}

View File

@ -0,0 +1,95 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.GenericStatusMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.ProtobufStatusMessage;
import static nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.ProtobufStatusMessage.ProtobufChunkStatus.KEPT;
import static nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.ProtobufStatusMessage.ProtobufStatusCode.NO_ERROR;
public class ProtobufMessage extends GFDIMessage {
private final int requestId;
private final int dataOffset;
private final int totalProtobufLength;
private final int protobufDataLength;
private final byte[] messageBytes;
private final boolean sendOutgoing;
public ProtobufMessage(GarminMessage garminMessage, int requestId, int dataOffset, int totalProtobufLength, int protobufDataLength, byte[] messageBytes, boolean sendOutgoing) {
this.garminMessage = garminMessage;
this.requestId = requestId;
this.dataOffset = dataOffset;
this.totalProtobufLength = totalProtobufLength;
this.protobufDataLength = protobufDataLength;
this.messageBytes = messageBytes;
this.sendOutgoing = sendOutgoing;
if (isComplete()) {
this.statusMessage = new GenericStatusMessage(garminMessage, GFDIMessage.Status.ACK);
} else {
this.statusMessage = new ProtobufStatusMessage(garminMessage, GFDIMessage.Status.ACK, requestId, dataOffset, KEPT, NO_ERROR);
}
}
public ProtobufMessage(GarminMessage garminMessage, int requestId, int dataOffset, int totalProtobufLength, int protobufDataLength, byte[] messageBytes) {
this(garminMessage, requestId, dataOffset, totalProtobufLength, protobufDataLength, messageBytes, true);
}
public void setStatusMessage(ProtobufStatusMessage protobufStatusMessage) {
this.statusMessage = protobufStatusMessage;
}
public static ProtobufMessage parseIncoming(MessageReader reader, GarminMessage garminMessage) {
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 ProtobufMessage(garminMessage, requestID, dataOffset, totalProtobufLength, protobufDataLength, messageBytes, false);
}
public int getRequestId() {
return requestId;
}
public GarminMessage getMessageType() {
return garminMessage;
}
public int getDataOffset() {
return dataOffset;
}
public int getTotalProtobufLength() {
return totalProtobufLength;
}
public byte[] getMessageBytes() {
return messageBytes;
}
public boolean isChunked() {
return (totalProtobufLength != protobufDataLength);
}
public boolean isComplete() {
return (dataOffset == 0 && !isChunked());
}
@Override
protected boolean generateOutgoing() {
final MessageWriter writer = new MessageWriter(response);
writer.writeShort(0); // packet size will be filled below
writer.writeShort(garminMessage.getId());
writer.writeShort(requestId);
writer.writeInt(dataOffset);
writer.writeInt(totalProtobufLength);
writer.writeInt(protobufDataLength);
writer.writeBytes(messageBytes);
return sendOutgoing;
}
}

View File

@ -0,0 +1,53 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
import java.util.Map;
public class SetDeviceSettingsMessage extends GFDIMessage {
private final Map<GarminDeviceSetting, Object> settings;
public SetDeviceSettingsMessage(Map<GarminDeviceSetting, Object> settings) {
this.garminMessage = GarminMessage.DEVICE_SETTINGS;
this.settings = settings;
final int settingsCount = settings.size();
if (settingsCount == 0) throw new IllegalArgumentException("Empty settings");
if (settingsCount > 255) throw new IllegalArgumentException("Too many settings");
}
@Override
protected boolean generateOutgoing() {
final MessageWriter writer = new MessageWriter(response);
writer.writeShort(0); // packet size will be filled below
writer.writeShort(this.garminMessage.getId());
writer.writeByte(settings.size());
for (Map.Entry<GarminDeviceSetting, Object> settingPair : settings.entrySet()) {
final GarminDeviceSetting setting = settingPair.getKey();
writer.writeByte(setting.ordinal());
final Object value = settingPair.getValue();
if (value instanceof String) {
writer.writeString((String) value);
} else if (value instanceof Integer) {
writer.writeByte(4);
writer.writeInt((Integer) value);
} else if (value instanceof Boolean) {
writer.writeByte(1);
writer.writeByte(Boolean.TRUE.equals(value) ? 1 : 0);
} else {
throw new IllegalArgumentException("Unsupported setting value type " + value);
}
}
return true;
}
public enum GarminDeviceSetting {
DEVICE_NAME,
CURRENT_TIME,
DAYLIGHT_SAVINGS_TIME_OFFSET,
TIME_ZONE_OFFSET,
NEXT_DAYLIGHT_SAVINGS_START,
NEXT_DAYLIGHT_SAVINGS_END,
AUTO_UPLOAD_ENABLED,
WEATHER_CONDITIONS_ENABLED,
WEATHER_ALERTS_ENABLED
}
}

View File

@ -0,0 +1,41 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
import org.apache.commons.lang3.EnumUtils;
import java.util.EnumSet;
public class SetFileFlagsMessage extends GFDIMessage {
private final int fileIndex;
private final FileFlags flags;
public SetFileFlagsMessage(int fileIndex, FileFlags flags) {
this.garminMessage = GarminMessage.SET_FILE_FLAG;
this.fileIndex = fileIndex;
this.flags = flags;
}
@Override
protected boolean generateOutgoing() {
final MessageWriter writer = new MessageWriter(response);
writer.writeShort(0); // packet size will be filled below
writer.writeShort(this.garminMessage.getId());
writer.writeShort(this.fileIndex);
writer.writeByte((int) EnumUtils.generateBitVector(FileFlags.class, this.flags));
return true;
}
public enum FileFlags {
UNK_00000001,
UNK_00000010,
UNK_00000100,
UNK_00001000,
ARCHIVE,
;
public static EnumSet<FileFlags> fromBitMask(final int code) {
return EnumUtils.processBitVector(FileFlags.class, code);
}
}
}

View File

@ -0,0 +1,16 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
public class SupportedFileTypesMessage extends GFDIMessage {
public SupportedFileTypesMessage() {
this.garminMessage = GarminMessage.SUPPORTED_FILE_TYPES_REQUEST;
}
@Override
protected boolean generateOutgoing() {
final MessageWriter writer = new MessageWriter(response);
writer.writeShort(0); // packet size will be filled below
writer.writeShort(this.garminMessage.getId());
return true;
}
}

View File

@ -0,0 +1,49 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
public class SystemEventMessage extends GFDIMessage {
private final GarminSystemEventType eventType;
private final Object value;
public SystemEventMessage(GarminSystemEventType eventType, Object value) {
this.eventType = eventType;
this.value = value;
this.garminMessage = GarminMessage.SYSTEM_EVENT;
}
@Override
protected boolean generateOutgoing() {
final MessageWriter writer = new MessageWriter(response);
writer.writeShort(0); // packet size will be filled below
writer.writeShort(this.garminMessage.getId());
writer.writeByte(eventType.ordinal());
if (value instanceof String) {
writer.writeString((String) value);
} else if (value instanceof Integer) {
writer.writeByte((Integer) value);
} else {
throw new IllegalArgumentException("Unsupported event value type " + value);
}
return true;
}
public enum GarminSystemEventType {
SYNC_COMPLETE,
SYNC_FAIL,
FACTORY_RESET,
PAIR_START,
PAIR_COMPLETE,
PAIR_FAIL,
HOST_DID_ENTER_FOREGROUND,
HOST_DID_ENTER_BACKGROUND,
SYNC_READY,
NEW_DOWNLOAD_AVAILABLE,
DEVICE_SOFTWARE_UPDATE,
DEVICE_DISCONNECT,
TUTORIAL_COMPLETE,
SETUP_WIZARD_START,
SETUP_WIZARD_COMPLETE,
SETUP_WIZARD_SKIPPED,
TIME_UPDATED
}
}

View File

@ -0,0 +1,20 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.GenericStatusMessage;
public class UnhandledMessage extends GFDIMessage {
private final int messageType;
public UnhandledMessage(int messageType) {
this.messageType = messageType;
this.statusMessage = new GenericStatusMessage(messageType, Status.UNSUPPORTED);
}
@Override
protected boolean generateOutgoing() {
return false;
}
}

View File

@ -0,0 +1,45 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
public class UploadRequestMessage extends GFDIMessage {
private final int fileIndex;
private final int size;
private final boolean generateOutgoing;
private final int dataOffset;
private final int crcSeed;
public UploadRequestMessage(GarminMessage garminMessage, int fileIndex, int size, int dataOffset, int crcSeed) {
this.garminMessage = garminMessage;
this.fileIndex = fileIndex;
this.size = size;
this.dataOffset = dataOffset;
this.crcSeed = crcSeed;
this.statusMessage = this.getStatusMessage();
this.generateOutgoing = false;
}
public UploadRequestMessage(int fileIndex, int size) {
this.garminMessage = GarminMessage.UPLOAD_REQUEST;
this.fileIndex = fileIndex;
this.size = size;
this.dataOffset = 0;
this.crcSeed = 0;
this.statusMessage = this.getStatusMessage();
this.generateOutgoing = true;
}
@Override
protected boolean generateOutgoing() {
final MessageWriter writer = new MessageWriter(response);
writer.writeShort(0); // packet size will be filled below
writer.writeShort(this.garminMessage.getId());
writer.writeShort(this.fileIndex);
writer.writeInt(this.size);
writer.writeInt(this.dataOffset);
writer.writeShort(this.crcSeed);
return generateOutgoing;
}
}

View File

@ -0,0 +1,37 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages;
import java.util.Collections;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents.WeatherRequestDeviceEvent;
public class WeatherMessage extends GFDIMessage {
private final WeatherRequestDeviceEvent weatherRequestDeviceEvent;
public WeatherMessage(int format, int latitude, int longitude, int hoursOfForecast, GarminMessage garminMessage) {
this.garminMessage = garminMessage;
weatherRequestDeviceEvent = new WeatherRequestDeviceEvent(format, latitude, longitude, hoursOfForecast);
this.statusMessage = this.getStatusMessage();
}
public static WeatherMessage parseIncoming(MessageReader reader, GarminMessage garminMessage) {
final int format = reader.readByte();
final int latitude = reader.readInt();
final int longitude = reader.readInt();
final int hoursOfForecast = reader.readByte();
return new WeatherMessage(format, latitude, longitude, hoursOfForecast, garminMessage);
}
public List<GBDeviceEvent> getGBDeviceEvent() {
return Collections.singletonList(weatherRequestDeviceEvent);
}
@Override
protected boolean generateOutgoing() {
return false;
}
}

View File

@ -0,0 +1,72 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.FileType;
public class CreateFileStatusMessage extends GFDIStatusMessage {
private final Status status;
private final CreateStatus createStatus;
private final FileType.FILETYPE filetype;
private final int fileIndex;
private final int fileNumber;
public CreateFileStatusMessage(GarminMessage garminMessage, Status status, CreateStatus createStatus, int fileIndex, FileType.FILETYPE filetype, int fileNumber) {
this.garminMessage = garminMessage;
this.status = status;
this.createStatus = createStatus;
this.fileIndex = fileIndex;
this.filetype = filetype;
this.fileNumber = fileNumber;
}
public static CreateFileStatusMessage parseIncoming(MessageReader reader, GarminMessage garminMessage) {
final Status status = Status.fromCode(reader.readByte());
if (!status.equals(Status.ACK)) {
return null;
}
final CreateStatus createStatus = CreateStatus.fromId(reader.readByte());
int fileIndex = reader.readShort();
final int dataType = reader.readByte();
final int subType = reader.readByte();
final FileType.FILETYPE filetype = FileType.FILETYPE.fromDataTypeSubType(dataType, subType);
final int fileNumber = reader.readShort();
if (!CreateStatus.OK.equals(createStatus)) {
LOG.warn("Received {} / {} for message {}", status, createStatus, garminMessage);
} else {
LOG.info("Received {} / {} for message {}", status, createStatus, garminMessage);
}
return new CreateFileStatusMessage(garminMessage, status, createStatus, fileIndex, filetype, fileNumber);
}
public int getFileIndex() {
return fileIndex;
}
public int getFileNumber() {
return fileNumber;
}
public boolean canProceed() {
return status.equals(Status.ACK) && createStatus.equals(CreateStatus.OK);
}
public enum CreateStatus {
OK,
DUPLICATE,
NO_SPACE,
UNSUPPORTED,
NO_SLOTS,
NO_SPACE_FOR_TYPE,
;
public static CreateStatus fromId(int id) {
for (CreateStatus createStatus :
CreateStatus.values()) {
if (createStatus.ordinal() == id) {
return createStatus;
}
}
return null;
}
}
}

View File

@ -0,0 +1,60 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status;
public class DownloadRequestStatusMessage extends GFDIStatusMessage {
private final Status status;
private final DownloadStatus downloadStatus;
private final int maxFileSize;
public DownloadRequestStatusMessage(GarminMessage garminMessage, Status status, DownloadStatus downloadStatus, int maxFileSize) {
this.garminMessage = garminMessage;
this.status = status;
this.downloadStatus = downloadStatus;
this.maxFileSize = maxFileSize;
}
public static DownloadRequestStatusMessage parseIncoming(MessageReader reader, GarminMessage garminMessage) {
final Status status = Status.fromCode(reader.readByte());
if (!status.equals(Status.ACK)) {
return null;
}
final DownloadStatus downloadStatus = DownloadStatus.fromId(reader.readByte());
final int maxFileSize = reader.readInt();
if (!DownloadStatus.OK.equals(downloadStatus)) {
LOG.warn("Received {} / {} for message {}", status, downloadStatus, garminMessage);
} else {
LOG.info("Received {} / {} for message {}", status, downloadStatus, garminMessage);
}
return new DownloadRequestStatusMessage(garminMessage, status, downloadStatus, maxFileSize);
}
public int getMaxFileSize() {
return maxFileSize;
}
public boolean canProceed() {
return status.equals(Status.ACK) && downloadStatus.equals(DownloadStatus.OK);
}
public enum DownloadStatus { //was DownloadRequestResponseMessage
OK,
INDEX_UNKNOWN,
INDEX_NOT_READABLE,
NO_SPACE_LEFT,
INVALID,
NOT_READY,
CRC_INCORRECT,
;
public static DownloadStatus fromId(int id) {
for (DownloadStatus downloadStatus :
DownloadStatus.values()) {
if (downloadStatus.ordinal() == id) {
return downloadStatus;
}
}
return null;
}
}
}

View File

@ -0,0 +1,80 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.MessageWriter;
public class FileTransferDataStatusMessage extends GFDIStatusMessage {
private final Status status;
private final TransferStatus transferStatus;
private final int dataOffset;
private final boolean sendOutgoing;
public FileTransferDataStatusMessage(GarminMessage garminMessage, Status status, TransferStatus transferStatus, int dataOffset) {
this(garminMessage, status, transferStatus, dataOffset, true);
}
public FileTransferDataStatusMessage(GarminMessage garminMessage, Status status, TransferStatus transferStatus, int dataOffset, boolean sendOutgoing) {
this.garminMessage = garminMessage;
this.status = status;
this.transferStatus = transferStatus;
this.dataOffset = dataOffset;
this.sendOutgoing = sendOutgoing;
}
public static FileTransferDataStatusMessage parseIncoming(MessageReader reader, GarminMessage garminMessage) {
final Status status = Status.fromCode(reader.readByte());
if (!status.equals(Status.ACK)) {
return null;
}
final TransferStatus transferStatus = TransferStatus.fromId(reader.readByte());
final int dataOffset = reader.readInt();
if (!TransferStatus.OK.equals(transferStatus)) {
LOG.warn("Received {} / {} for message {}", status, transferStatus, garminMessage);
} else {
LOG.info("Received {} / {} for message {}", status, transferStatus, garminMessage);
}
return new FileTransferDataStatusMessage(garminMessage, status, transferStatus, dataOffset, false);
}
public int getDataOffset() {
return dataOffset;
}
public boolean canProceed() {
return status.equals(Status.ACK) && transferStatus.equals(TransferStatus.OK);
}
@Override
protected boolean generateOutgoing() {
final MessageWriter writer = new MessageWriter(response);
writer.writeShort(0); // packet size will be filled below
writer.writeShort(GarminMessage.RESPONSE.getId());
writer.writeShort(garminMessage.getId());
writer.writeByte(status.ordinal());
writer.writeByte(transferStatus.ordinal());
writer.writeInt(dataOffset);
return sendOutgoing;
}
public enum TransferStatus {
OK,
RESEND,
ABORT,
CRC_MISMATCH,
OFFSET_MISMATCH,
SYNC_PAUSED,
;
public static TransferStatus fromId(int id) {
for (TransferStatus transferStatus :
TransferStatus.values()) {
if (transferStatus.ordinal() == id) {
return transferStatus;
}
}
return null;
}
}
}

View File

@ -0,0 +1,51 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status;
import androidx.annotation.Nullable;
public class FitDataStatusMessage extends GFDIStatusMessage {
private final Status status;
private final FitDataStatusCode fitDataStatusCode;
public FitDataStatusMessage(GarminMessage garminMessage, Status status, FitDataStatusCode fitDataStatusCode) {
this.garminMessage = garminMessage;
this.status = status;
this.fitDataStatusCode = fitDataStatusCode;
switch (fitDataStatusCode) {
case APPLIED:
LOG.info("FIT DATA RETURNED STATUS: {}", fitDataStatusCode.name());
break;
default:
LOG.warn("FIT DATA RETURNED STATUS: {}", fitDataStatusCode.name());
}
}
public static FitDataStatusMessage parseIncoming(MessageReader reader, GarminMessage garminMessage) {
final Status status = Status.fromCode(reader.readByte());
final int fitDataStatusCodeByte = reader.readByte();
final FitDataStatusCode fitDataStatusCode = FitDataStatusCode.fromCode(fitDataStatusCodeByte);
if (fitDataStatusCode == null) {
LOG.warn("Unknown fit data status code {}", fitDataStatusCodeByte);
return null;
}
return new FitDataStatusMessage(garminMessage, status, fitDataStatusCode);
}
public enum FitDataStatusCode {
APPLIED,
NO_DEFINITION,
MISMATCH,
NOT_READY,
;
@Nullable
public static FitDataStatusCode fromCode(final int code) {
for (final FitDataStatusCode fitDataStatusCode : FitDataStatusCode.values()) {
if (fitDataStatusCode.ordinal() == code) {
return fitDataStatusCode;
}
}
return null;
}
}
}

View File

@ -0,0 +1,51 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status;
import androidx.annotation.Nullable;
public class FitDefinitionStatusMessage extends GFDIStatusMessage {
private final Status status;
private final FitDefinitionStatusCode fitDefinitionStatusCode;
public FitDefinitionStatusMessage(GarminMessage garminMessage, Status status, FitDefinitionStatusCode fitDefinitionStatusCode) {
this.garminMessage = garminMessage;
this.status = status;
this.fitDefinitionStatusCode = fitDefinitionStatusCode;
switch (fitDefinitionStatusCode) {
case APPLIED:
LOG.info("FIT DEFINITION RETURNED STATUS: {}", fitDefinitionStatusCode.name());
break;
default:
LOG.warn("FIT DEFINITION RETURNED STATUS: {}", fitDefinitionStatusCode.name());
}
}
public static FitDefinitionStatusMessage parseIncoming(MessageReader reader, GarminMessage garminMessage) {
final Status status = Status.fromCode(reader.readByte());
final int fitDefinitionStatusCodeByte = reader.readByte();
final FitDefinitionStatusCode fitDefinitionStatusCode = FitDefinitionStatusCode.fromCode(fitDefinitionStatusCodeByte);
if (fitDefinitionStatusCode == null) {
LOG.warn("Unknown fit definition status code {}", fitDefinitionStatusCodeByte);
return null;
}
return new FitDefinitionStatusMessage(garminMessage, status, fitDefinitionStatusCode);
}
public enum FitDefinitionStatusCode {
APPLIED,
NOT_UNIQUE,
OUT_OF_RANGE,
NOT_READY,
;
@Nullable
public static FitDefinitionStatusCode fromCode(final int code) {
for (final FitDefinitionStatusCode fitDefinitionStatusCode : FitDefinitionStatusCode.values()) {
if (fitDefinitionStatusCode.ordinal() == code) {
return fitDefinitionStatusCode;
}
}
return null;
}
}
}

View File

@ -0,0 +1,56 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.GFDIMessage;
public abstract class GFDIStatusMessage extends GFDIMessage {
private Status status;
public static GFDIStatusMessage parseIncoming(MessageReader reader, GarminMessage garminMessage) {
int originalMessageType = reader.readShort();
final GarminMessage originalGarminMessage = GFDIMessage.GarminMessage.fromId(originalMessageType);
if (GarminMessage.PROTOBUF_REQUEST.equals(originalGarminMessage) || GarminMessage.PROTOBUF_RESPONSE.equals(originalGarminMessage)) {
return ProtobufStatusMessage.parseIncoming(reader, originalGarminMessage);
} else if (GarminMessage.NOTIFICATION_DATA.equals(originalGarminMessage)) {
return NotificationDataStatusMessage.parseIncoming(reader, originalGarminMessage);
} else if (GarminMessage.UPLOAD_REQUEST.equals(originalGarminMessage)) {
return UploadRequestStatusMessage.parseIncoming(reader, originalGarminMessage);
} else if (GarminMessage.DOWNLOAD_REQUEST.equals(originalGarminMessage)) {
return DownloadRequestStatusMessage.parseIncoming(reader, originalGarminMessage);
} else if (GarminMessage.FILE_TRANSFER_DATA.equals(originalGarminMessage)) {
return FileTransferDataStatusMessage.parseIncoming(reader, originalGarminMessage);
} else if (GarminMessage.CREATE_FILE.equals(originalGarminMessage)) {
return CreateFileStatusMessage.parseIncoming(reader, originalGarminMessage);
} else if (GarminMessage.SUPPORTED_FILE_TYPES_REQUEST.equals(originalGarminMessage)) {
SupportedFileTypesStatusMessage supportedFileTypesStatusMessage = SupportedFileTypesStatusMessage.parseIncoming(reader, garminMessage);
LOG.info("{}", supportedFileTypesStatusMessage);
return supportedFileTypesStatusMessage;
} else if (GarminMessage.SET_FILE_FLAG.equals(originalGarminMessage)) {
return SetFileFlagsStatusMessage.parseIncoming(reader, garminMessage);
} else if (GarminMessage.FIT_DEFINITION.equals(originalGarminMessage)) {
return FitDefinitionStatusMessage.parseIncoming(reader, originalGarminMessage);
} else if (GarminMessage.FIT_DATA.equals(originalGarminMessage)) {
return FitDataStatusMessage.parseIncoming(reader, originalGarminMessage);
} else {
final Status status = Status.fromCode(reader.readByte());
if (Status.ACK == status) {
LOG.info("Received ACK for message {}", originalGarminMessage);
} else {
LOG.warn("Received {} for message {}", status, (null == originalGarminMessage) ? originalMessageType : originalGarminMessage.name());
}
return new GenericStatusMessage(garminMessage, status);
}
}
@Override
protected boolean generateOutgoing() {
return false;
}
protected Status getStatus() {
return status;
}
}

View File

@ -0,0 +1,30 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.MessageWriter;
public class GenericStatusMessage extends GFDIStatusMessage {
private final Status status;
private int messageType; // for unsupported message types
public GenericStatusMessage(GarminMessage originalMessage, Status status) {
this.garminMessage = originalMessage;
this.status = status;
}
public GenericStatusMessage(int messageType, Status status) {
this.messageType = messageType;
this.status = status;
}
@Override
protected boolean generateOutgoing() {
final MessageWriter writer = new MessageWriter(response);
writer.writeShort(0); // packet size will be filled below
writer.writeShort(GarminMessage.RESPONSE.getId());
writer.writeShort(messageType != 0 ? messageType : garminMessage.getId());
writer.writeByte(status.ordinal());
return true;
}
}

View File

@ -0,0 +1,76 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status;
import androidx.annotation.Nullable;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.MessageWriter;
public class NotificationControlStatusMessage extends GFDIStatusMessage {
private final Status status;
private final NotificationChunkStatus notificationChunkStatus;
private final NotificationStatusCode notificationStatusCode;
private final boolean sendOutgoing;
public NotificationControlStatusMessage(GarminMessage garminMessage, Status status, NotificationChunkStatus notificationChunkStatus, NotificationStatusCode notificationStatusCode) {
this.garminMessage = garminMessage;
this.status = status;
this.notificationChunkStatus = notificationChunkStatus;
this.notificationStatusCode = notificationStatusCode;
this.sendOutgoing = true;
}
@Override
protected boolean generateOutgoing() {
final MessageWriter writer = new MessageWriter(response);
writer.writeShort(0); // packet size will be filled below
writer.writeShort(GarminMessage.RESPONSE.getId());
writer.writeShort(garminMessage.getId());
writer.writeByte(status.ordinal());
writer.writeByte(notificationChunkStatus.ordinal());
writer.writeByte(notificationStatusCode.getCode());
return this.sendOutgoing;
}
public enum NotificationChunkStatus {
OK,
ERROR,
;
public static NotificationChunkStatus fromId(int id) {
for (NotificationChunkStatus notificationChunkStatus :
NotificationChunkStatus.values()) {
if (notificationChunkStatus.ordinal() == id) {
return notificationChunkStatus;
}
}
return null;
}
}
public enum NotificationStatusCode {
NO_ERROR(0),
UNKNOWN_COMMAND(160),
;
private final int code;
NotificationStatusCode(final int code) {
this.code = code;
}
@Nullable
public static NotificationStatusCode fromCode(final int code) {
for (final NotificationStatusCode notificationStatusCode : NotificationStatusCode.values()) {
if (notificationStatusCode.getCode() == code) {
return notificationStatusCode;
}
}
return null;
}
public int getCode() {
return code;
}
}
}

Some files were not shown because too many files have changed in this diff Show More