Compare commits
99 Commits
2bfbb75c0b
...
226277bcdc
Author | SHA1 | Date |
---|---|---|
Daniele Gobbetti | 226277bcdc | |
kuhy | 60066014e0 | |
kuhy | eeb80d712b | |
kuhy | ad44459fc4 | |
kuhy | c9cb7d788e | |
kuhy | 5841058863 | |
Daniele Gobbetti | f3b07694b2 | |
Daniele Gobbetti | 4d06eb7339 | |
José Rebelo | 6c79b42130 | |
Daniele Gobbetti | fee3b9188c | |
Daniele Gobbetti | 4eabf87e18 | |
Andreas Schneider | d0f0db833c | |
Daniele Gobbetti | 36781e6958 | |
Daniele Gobbetti | 4faf95a417 | |
meskio | 709544e6bd | |
José Rebelo | 1328ce13e1 | |
Daniele Gobbetti | d240597bfe | |
José Rebelo | eec88c3dd5 | |
Daniele Gobbetti | 7700154b16 | |
a0z | 1dec34afea | |
Daniele Gobbetti | eba72bd40f | |
José Rebelo | 915d059c1c | |
José Rebelo | de9e087e2d | |
José Rebelo | 27b0a5ce49 | |
José Rebelo | 36c52e1900 | |
Daniele Gobbetti | b75ecae454 | |
José Rebelo | bcb8f7504d | |
José Rebelo | 46ea7d87b1 | |
Daniele Gobbetti | 77e64b149b | |
kuhy | f46929c080 | |
Daniele Gobbetti | a7d4f3df93 | |
myxor | d72113c0cb | |
Daniele Gobbetti | bdfab59a81 | |
Daniele Gobbetti | ce968e1ea8 | |
Daniele Gobbetti | e6f78bbba4 | |
hrdl | 2673edf05b | |
Daniele Gobbetti | 9d2a42b173 | |
José Rebelo | a21ceb606c | |
José Rebelo | b0932d0f17 | |
José Rebelo | 3dab968805 | |
José Rebelo | 9c7aa8c22b | |
José Rebelo | 207ab89448 | |
José Rebelo | 4c3092089e | |
José Rebelo | 993765b3c6 | |
Daniele Gobbetti | 554e33adaa | |
Daniele Gobbetti | 951f550b87 | |
Daniele Gobbetti | ed0f8077e7 | |
Daniele Gobbetti | f90b544dc9 | |
Daniele Gobbetti | a5bf32b9f1 | |
Daniele Gobbetti | e18d7df513 | |
Daniele Gobbetti | e1bfd05523 | |
Daniele Gobbetti | 3ea737d89c | |
Daniele Gobbetti | 6b7db3d92d | |
Daniele Gobbetti | 74facd4505 | |
Daniele Gobbetti | 9152a5c3da | |
Daniele Gobbetti | 07d4dd9dcb | |
Daniele Gobbetti | ae347bed98 | |
Daniele Gobbetti | 4a38b7aee8 | |
Daniele Gobbetti | 135073585e | |
Martin.JM | 4c93647aaf | |
José Rebelo | 881e8e36e8 | |
José Rebelo | 0ff8774fce | |
rymut | 7a50df61b8 | |
rymut | 8860b4b678 | |
Nyatsuki | b8852379f9 | |
Baka Gaijin | 07a11addb9 | |
あぽろあぽろ | 4dde33c342 | |
Deleted User | 294adf6da5 | |
José Rebelo | 5c7ea9131e | |
0que | 27fa1a94fe | |
summoner001 | 80857758b4 | |
Balage | 153199b3b4 | |
Sergey Ponomarev | c7a29e4499 | |
Nyatsuki | 7b05853b7d | |
summoner001 | d543dcdd80 | |
0que | 58c4242ba5 | |
Hikaru | 1d53259988 | |
Nyatsuki | f596c3b83c | |
ritchierope | 2ed0be0bcd | |
summoner001 | a94e1eb573 | |
Stepan | 6fd9414d37 | |
glemco | 05ffd79815 | |
Yaron Shahrabani | 82dfbce231 | |
0que | 3d35e322e9 | |
bowornsin | f2002fc9a9 | |
Linerly | 9caf07657d | |
Rex_sa | 1c7c7ff4d6 | |
陈少举 | a8dbb30139 | |
arjan-s | 59e9d01605 | |
Mikachu | e99a7654af | |
Oğuz Ersen | 30de0cda70 | |
gallegonovato | ccbfeb11d0 | |
skdubg | e6e87f9ff7 | |
Martin.JM | 83fd09939f | |
Damien 'Psolyca' Gaignon | 2d32822ff8 | |
José Rebelo | 772ec05049 | |
José Rebelo | 18e08d13da | |
Martin.JM | 1c2c1f710e | |
José Rebelo | 013ffe5559 |
44
CHANGELOG.md
44
CHANGELOG.md
|
@ -2,21 +2,61 @@
|
|||
|
||||
#### Next release (WIP)
|
||||
|
||||
* Experimental support for Redmi Watch 4
|
||||
* Initial support for Huawei Watch Fit 2
|
||||
* Introduce new Dashboard view
|
||||
* AsteroidOS: Added icons to the notifications
|
||||
* Bangle.js: Add screenshot support
|
||||
* Bangle.js: Add setting to disable notifications
|
||||
* Bangle.js: Allow wake phone when opening notification response from watch
|
||||
* Bangle.js: Fix activity intensity normalization
|
||||
* Bangle.js: Fix message reply
|
||||
* Fossil/Skagen Hybrids: Update device settings to new structure
|
||||
* Galaxy Buds Live: Update device settings to new structure
|
||||
* HPlus: Migrate global preferences to device-specific
|
||||
* Huawei: Add cycling workout type
|
||||
* Huawei: Add enable HeartRate and SpO2 force option
|
||||
* Huawei: Add huawei account support (pair without resetting watch)
|
||||
* Huawei: Add support for workout calories and cycling power
|
||||
* Huawei: Ask pincode only on first connection
|
||||
* Huawei: Enable sleep detection
|
||||
* Huawei: File upload and watchface management
|
||||
* Huawei: Fix force DND support
|
||||
* Huawei: Fix long notification
|
||||
* Huawei: Fix TimeZone offset calculation
|
||||
* Huawei: Improve connection and reconnection
|
||||
* Huawei: Improve notification icons
|
||||
* Huawei: Improve workout parsing
|
||||
* Huawei: Rework settings menu with sub-screens
|
||||
* Huawei: Support sending GPS to band
|
||||
* Huawei Watch GT4: Add HR and SpO support
|
||||
* Huawei Watch Ultimate: Add HR and SpO support
|
||||
* Intent API: Added debug end call
|
||||
* Mi Band 6: Add menu items for NFC shortcuts
|
||||
* Nothing CMF Watch Pro: Add weather support
|
||||
* Nothing Earbuds: Add adjustable delay for auto-pick-up of calls
|
||||
* Nothing Earbuds: Add option to auto-reply to incoming phone calls
|
||||
* Nothing Earbuds: Add option to read aloud incoming notifications
|
||||
* Xiaomi Smart Band 8 Active: Fix discovery
|
||||
* Xiaomi: Fix some crashes
|
||||
* Xiaomi: Improve reconnection
|
||||
* Xiaomi: Improve weather support, add multiple locations
|
||||
* Set navbar color to match theme
|
||||
* Xiaomi: Sync calendar event reminders
|
||||
* Zepp OS: Add support for Sleep as Android
|
||||
* Zepp OS: Sync calendar event reminders
|
||||
* Add Armenian and Serbian transliterators
|
||||
* Add GENERIC_PHONE and GENERIC_CALENDAR NotificationType handling
|
||||
* Add support for scannable-only devices
|
||||
* Fix crash when connecting on some phones
|
||||
* Fix crash when enabling bluetooth
|
||||
* Fix receiving shared gpx files
|
||||
* Format pace as mm:ss
|
||||
* Set navbar color to match theme
|
||||
* Simplify pairing of bonded and companion devices
|
||||
* Prevent text cutoff on all checkbox preferences
|
||||
* Recognize "Delta Chat" as generic chat
|
||||
* Remove deprecated general auto-reconnect preference
|
||||
* Refactor location service
|
||||
* Fix text cutoff on all checkbox preferences
|
||||
|
||||
#### 0.80.0
|
||||
* Initial support for Amazfit Bip 3
|
||||
|
|
|
@ -45,7 +45,7 @@ public class GBDaoGenerator {
|
|||
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
final Schema schema = new Schema(71, MAIN_PACKAGE + ".entities");
|
||||
final Schema schema = new Schema(73, MAIN_PACKAGE + ".entities");
|
||||
|
||||
Entity userAttributes = addUserAttributes(schema);
|
||||
Entity user = addUserInfo(schema, userAttributes);
|
||||
|
@ -1167,6 +1167,12 @@ public class GBDaoGenerator {
|
|||
|
||||
workoutDataSample.addByteArrayProperty("dataErrorHex");
|
||||
|
||||
workoutDataSample.addShortProperty("calories").notNull();
|
||||
workoutDataSample.addShortProperty("cyclingPower").notNull();
|
||||
|
||||
workoutDataSample.addShortProperty("frequency").notNull();
|
||||
workoutDataSample.addIntProperty("altitude");
|
||||
|
||||
return workoutDataSample;
|
||||
}
|
||||
|
||||
|
|
|
@ -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.**
|
||||
|
||||
|
|
|
@ -178,10 +178,6 @@
|
|||
android:name=".devices.pebble.PebbleSettingsActivity"
|
||||
android:label="@string/pref_title_pebble_settings"
|
||||
android:parentActivityName=".activities.SettingsActivity" />
|
||||
<activity
|
||||
android:name=".devices.hplus.HPlusSettingsActivity"
|
||||
android:label="@string/preferences_hplus_settings"
|
||||
android:parentActivityName=".activities.SettingsActivity" />
|
||||
<activity
|
||||
android:name=".devices.zetime.ZeTimePreferenceActivity"
|
||||
android:label="@string/zetime_title_settings"
|
||||
|
|
|
@ -71,6 +71,7 @@ import java.util.Comparator;
|
|||
import java.util.Date;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
|
||||
|
@ -518,6 +519,14 @@ public class ActivitySummaryDetail extends AbstractGBActivity {
|
|||
|
||||
if (unit.equals("seconds") && !show_raw_data) { //rather then plain seconds, show formatted duration
|
||||
value_field.setText(DateTimeUtils.formatDurationHoursMinutes((long) value, TimeUnit.SECONDS));
|
||||
} else if (unit.equals("minutes_km") || unit.equals("minutes_mi")) {
|
||||
// Format pace
|
||||
value_field.setText(String.format(
|
||||
Locale.getDefault(),
|
||||
"%d:%02d %s",
|
||||
(int) Math.floor(value), (int) Math.round(60 * (value - (int) Math.floor(value))),
|
||||
getStringResourceByName(unit)
|
||||
));
|
||||
} else {
|
||||
value_field.setText(String.format("%s %s", df.format(value), getStringResourceByName(unit)));
|
||||
}
|
||||
|
|
|
@ -220,6 +220,12 @@ public class DebugActivity extends AbstractGBActivity {
|
|||
replyAction.title = "Reply";
|
||||
replyAction.type = NotificationSpec.Action.TYPE_SYNTECTIC_REPLY_PHONENR;
|
||||
notificationSpec.attachedActions.add(replyAction);
|
||||
} else if (notificationSpec.type == NotificationType.CONVERSATIONS) {
|
||||
// REPLY action
|
||||
NotificationSpec.Action replyAction = new NotificationSpec.Action();
|
||||
replyAction.title = "Reply";
|
||||
replyAction.type = NotificationSpec.Action.TYPE_WEARABLE_REPLY;
|
||||
notificationSpec.attachedActions.add(replyAction);
|
||||
}
|
||||
|
||||
GBApplication.deviceService().onNotification(notificationSpec);
|
||||
|
@ -1184,7 +1190,10 @@ public class DebugActivity extends AbstractGBActivity {
|
|||
for (DeviceType deviceType : DeviceType.values()) {
|
||||
DeviceCoordinator coordinator = deviceType.getDeviceCoordinator();
|
||||
int icon = coordinator.getDefaultIconResource();
|
||||
String name = app.getString(coordinator.getDeviceNameResource()) + " (" + coordinator.getManufacturer() + ")";
|
||||
String name = app.getString(coordinator.getDeviceNameResource());
|
||||
if (!name.startsWith(coordinator.getManufacturer())) {
|
||||
name += " (" + coordinator.getManufacturer() + ")";
|
||||
}
|
||||
long deviceId = deviceType.ordinal();
|
||||
newMap.put(name, new Pair(deviceId, icon));
|
||||
}
|
||||
|
|
|
@ -231,6 +231,7 @@ public class DeviceSettingsPreferenceConst {
|
|||
public static final String PREF_AGPS_EXPIRY_REMINDER_TIME = "pref_agps_expiry_reminder_time";
|
||||
public static final String PREF_AGPS_UPDATE_TIME = "pref_agps_update_time";
|
||||
public static final String PREF_AGPS_EXPIRE_TIME = "pref_agps_expire_time";
|
||||
public static final String PREF_AGPS_STATUS = "pref_agps_status";
|
||||
|
||||
public static final String PREF_FIND_PHONE = "prefs_find_phone";
|
||||
public static final String PREF_FIND_PHONE_DURATION = "prefs_find_phone_duration";
|
||||
|
@ -441,4 +442,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";
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
@ -356,7 +356,7 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i
|
|||
});
|
||||
}
|
||||
|
||||
|
||||
addPreferenceHandlerFor(PREF_SEND_APP_NOTIFICATIONS);
|
||||
addPreferenceHandlerFor(PREF_SWIPE_UNLOCK);
|
||||
addPreferenceHandlerFor(PREF_MI2_DATEFORMAT);
|
||||
addPreferenceHandlerFor(PREF_DATEFORMAT);
|
||||
|
@ -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);
|
||||
|
|
|
@ -30,6 +30,7 @@ public enum DeviceSpecificSettingsScreen {
|
|||
DEVELOPER("pref_screen_developer", R.xml.devicesettings_root_developer),
|
||||
DISPLAY("pref_screen_display", R.xml.devicesettings_root_display),
|
||||
GENERIC("pref_screen_generic", R.xml.devicesettings_root_generic),
|
||||
LOCATION("pref_screen_location", R.xml.devicesettings_root_location),
|
||||
NOTIFICATIONS("pref_screen_notifications", R.xml.devicesettings_root_notifications),
|
||||
DATE_TIME("pref_screen_date_time", R.xml.devicesettings_root_date_time),
|
||||
WORKOUT("pref_screen_workout", R.xml.devicesettings_root_workout),
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
/* Copyright (C) 2024 Martin.JM
|
||||
|
||||
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.database.schema;
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
|
||||
import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiWorkoutDataSampleDao;
|
||||
|
||||
public class GadgetbridgeUpdate_72 implements DBUpdateScript {
|
||||
@Override
|
||||
public void upgradeSchema(final SQLiteDatabase db) {
|
||||
if (!DBHelper.existsColumn(HuaweiWorkoutDataSampleDao.TABLENAME, HuaweiWorkoutDataSampleDao.Properties.Calories.columnName, db)) {
|
||||
final String statement = "ALTER TABLE " + HuaweiWorkoutDataSampleDao.TABLENAME + " ADD COLUMN \""
|
||||
+ HuaweiWorkoutDataSampleDao.Properties.Calories.columnName + "\" INTEGER NOT NULL DEFAULT -1";
|
||||
db.execSQL(statement);
|
||||
}
|
||||
if (!DBHelper.existsColumn(HuaweiWorkoutDataSampleDao.TABLENAME, HuaweiWorkoutDataSampleDao.Properties.CyclingPower.columnName, db)) {
|
||||
final String statement = "ALTER TABLE " + HuaweiWorkoutDataSampleDao.TABLENAME + " ADD COLUMN \""
|
||||
+ HuaweiWorkoutDataSampleDao.Properties.CyclingPower.columnName + "\" INTEGER NOT NULL DEFAULT -1";
|
||||
db.execSQL(statement);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void downgradeSchema(final SQLiteDatabase db) {
|
||||
}
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
/* Copyright (C) 2024 Martin.JM
|
||||
|
||||
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.database.schema;
|
||||
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
|
||||
import nodomain.freeyourgadget.gadgetbridge.database.DBUpdateScript;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.HuaweiWorkoutDataSampleDao;
|
||||
|
||||
public class GadgetbridgeUpdate_73 implements DBUpdateScript {
|
||||
@Override
|
||||
public void upgradeSchema(final SQLiteDatabase db) {
|
||||
if (!DBHelper.existsColumn(HuaweiWorkoutDataSampleDao.TABLENAME, HuaweiWorkoutDataSampleDao.Properties.Frequency.columnName, db)) {
|
||||
final String statement = "ALTER TABLE " + HuaweiWorkoutDataSampleDao.TABLENAME + " ADD COLUMN \""
|
||||
+ HuaweiWorkoutDataSampleDao.Properties.Frequency.columnName + "\" INTEGER NOT NULL DEFAULT -1";
|
||||
db.execSQL(statement);
|
||||
}
|
||||
if (!DBHelper.existsColumn(HuaweiWorkoutDataSampleDao.TABLENAME, HuaweiWorkoutDataSampleDao.Properties.Altitude.columnName, db)) {
|
||||
final String statement = "ALTER TABLE " + HuaweiWorkoutDataSampleDao.TABLENAME + " ADD COLUMN \""
|
||||
+ HuaweiWorkoutDataSampleDao.Properties.Altitude.columnName + "\" INTEGER DEFAULT NULL";
|
||||
db.execSQL(statement);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void downgradeSchema(final SQLiteDatabase db) {
|
||||
}
|
||||
}
|
|
@ -79,6 +79,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.StressSample;
|
|||
import nodomain.freeyourgadget.gadgetbridge.model.TemperatureSample;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.ServiceDeviceSupport;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.SleepAsAndroidSender;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
|
||||
|
||||
|
@ -290,6 +291,18 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator {
|
|||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public File getWritableExportDirectory(final GBDevice device) throws IOException {
|
||||
File dir;
|
||||
dir = new File(FileUtils.getExternalFilesDir() + File.separator + device.getAddress());
|
||||
if (!dir.isDirectory()) {
|
||||
if (!dir.mkdir()) {
|
||||
throw new IOException("Cannot create device specific directory for " + device.getName());
|
||||
}
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAppCacheSortFilename() {
|
||||
return null;
|
||||
|
|
|
@ -442,6 +442,11 @@ public interface DeviceCoordinator {
|
|||
*/
|
||||
File getAppCacheDir() throws IOException;
|
||||
|
||||
/**
|
||||
* Returns the dedicated writable export directory for this device.
|
||||
*/
|
||||
File getWritableExportDirectory(GBDevice device) throws IOException;
|
||||
|
||||
/**
|
||||
* Returns a String containing the device app sort order filename.
|
||||
*/
|
||||
|
|
|
@ -0,0 +1,122 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.R;
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.InstallActivity;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
|
||||
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.GenericItem;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.agps.GarminAgpsFile;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.UriHelper;
|
||||
|
||||
public class GarminAgpsInstallHandler implements InstallHandler {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(GarminAgpsInstallHandler.class);
|
||||
|
||||
protected final Context mContext;
|
||||
private GarminAgpsFile file;
|
||||
|
||||
public GarminAgpsInstallHandler(final Uri uri, final Context context) {
|
||||
this.mContext = context;
|
||||
|
||||
final UriHelper uriHelper;
|
||||
try {
|
||||
uriHelper = UriHelper.get(uri, context);
|
||||
} catch (final IOException e) {
|
||||
LOG.error("Failed to get uri", e);
|
||||
return;
|
||||
}
|
||||
|
||||
try (InputStream in = new BufferedInputStream(uriHelper.openInputStream())) {
|
||||
final byte[] rawBytes = FileUtils.readAll(in, 1024 * 1024); // 1MB, they're usually ~60KB
|
||||
final GarminAgpsFile agpsFile = new GarminAgpsFile(rawBytes);
|
||||
if (agpsFile.isValid()) {
|
||||
this.file = agpsFile;
|
||||
}
|
||||
} catch (final Exception e) {
|
||||
LOG.error("Failed to read file", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid() {
|
||||
return file != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validateInstallation(final InstallActivity installActivity, final GBDevice device) {
|
||||
if (device.isBusy()) {
|
||||
installActivity.setInfoText(device.getBusyTask());
|
||||
installActivity.setInstallEnabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
final DeviceCoordinator coordinator = device.getDeviceCoordinator();
|
||||
if (!(coordinator instanceof GarminCoordinator)) {
|
||||
LOG.warn("Coordinator is not a GarminCoordinator: {}", coordinator.getClass());
|
||||
installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_supported));
|
||||
installActivity.setInstallEnabled(false);
|
||||
return;
|
||||
}
|
||||
final GarminCoordinator garminCoordinator = (GarminCoordinator) coordinator;
|
||||
if (!garminCoordinator.supportsAgpsUpdates()) {
|
||||
installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_supported));
|
||||
installActivity.setInstallEnabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!device.isInitialized()) {
|
||||
installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_ready));
|
||||
installActivity.setInstallEnabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
final GenericItem fwItem = createInstallItem(device);
|
||||
fwItem.setIcon(coordinator.getDefaultIconResource());
|
||||
|
||||
if (file == null) {
|
||||
fwItem.setDetails(mContext.getString(R.string.miband_fwinstaller_incompatible_version));
|
||||
installActivity.setInfoText(mContext.getString(R.string.fwinstaller_firmware_not_compatible_to_device));
|
||||
installActivity.setInstallEnabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
final StringBuilder builder = new StringBuilder();
|
||||
final String agpsBundle = mContext.getString(R.string.kind_agps_bundle);
|
||||
builder.append(mContext.getString(R.string.fw_upgrade_notice, agpsBundle));
|
||||
builder.append("\n\n").append(mContext.getString(R.string.miband_firmware_unknown_warning));
|
||||
fwItem.setDetails(mContext.getString(R.string.miband_fwinstaller_untested_version));
|
||||
installActivity.setInfoText(builder.toString());
|
||||
installActivity.setInstallItem(fwItem);
|
||||
installActivity.setInstallEnabled(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStartInstall(final GBDevice device) {
|
||||
}
|
||||
|
||||
public GarminAgpsFile getFile() {
|
||||
return file;
|
||||
}
|
||||
|
||||
private GenericItem createInstallItem(final GBDevice device) {
|
||||
DeviceCoordinator coordinator = device.getDeviceCoordinator();
|
||||
final String firmwareName = mContext.getString(
|
||||
R.string.installhandler_firmware_name,
|
||||
mContext.getString(coordinator.getDeviceNameResource()),
|
||||
mContext.getString(R.string.kind_agps_bundle),
|
||||
""
|
||||
);
|
||||
return new GenericItem(firmwareName);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,124 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
|
||||
import nodomain.freeyourgadget.gadgetbridge.GBException;
|
||||
import nodomain.freeyourgadget.gadgetbridge.R;
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
|
||||
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;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
|
||||
|
||||
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> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALLS_AND_NOTIFICATIONS);
|
||||
|
||||
notifications.add(R.xml.devicesettings_send_app_notifications);
|
||||
|
||||
if (getCannedRepliesSlotCount(device) > 0) {
|
||||
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> location = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.LOCATION);
|
||||
location.add(R.xml.devicesettings_workout_send_gps_to_band);
|
||||
if (supportsAgpsUpdates()) {
|
||||
location.add(R.xml.devicesettings_garmin_agps);
|
||||
}
|
||||
|
||||
final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
|
||||
connection.add(R.xml.devicesettings_high_mtu);
|
||||
|
||||
final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
|
||||
developer.add(R.xml.devicesettings_keep_activity_data_on_device);
|
||||
|
||||
return deviceSpecificSettings;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(GBDevice device) {
|
||||
return new GarminSettingsCustomizer();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsActivityDataFetching() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsFindDevice() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsWeather() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCannedRepliesSlotCount(final GBDevice device) {
|
||||
if (getPrefs(device).getBoolean(GarminPreferences.PREF_FEAT_CANNED_MESSAGES, false)) {
|
||||
return 16;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected static Prefs getPrefs(final GBDevice device) {
|
||||
return new Prefs(GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsUnicodeEmojis() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InstallHandler findInstallHandler(final Uri uri, final Context context) {
|
||||
if (supportsAgpsUpdates()) {
|
||||
final GarminAgpsInstallHandler agpsInstallHandler = new GarminAgpsInstallHandler(uri, context);
|
||||
if (agpsInstallHandler.isValid()) {
|
||||
return agpsInstallHandler;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public boolean supportsAgpsUpdates() {
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
|
||||
|
||||
public class GarminPreferences {
|
||||
public static final String PREF_GARMIN_CAPABILITIES = "garmin_capabilities";
|
||||
public static final String PREF_FEAT_CANNED_MESSAGES = "feat_canned_messages";
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
|
||||
|
||||
import android.os.Parcel;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.preference.Preference;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.R;
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.agps.GarminAgpsStatus;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
|
||||
|
||||
public class GarminSettingsCustomizer implements DeviceSpecificSettingsCustomizer {
|
||||
|
||||
@Override
|
||||
public void onPreferenceChange(Preference preference, DeviceSpecificSettingsHandler handler) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void customizeSettings(DeviceSpecificSettingsHandler handler, Prefs prefs) {
|
||||
final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault());
|
||||
final Preference prefAgpsUpdateTime = handler.findPreference(DeviceSettingsPreferenceConst.PREF_AGPS_UPDATE_TIME);
|
||||
if (prefAgpsUpdateTime != null) {
|
||||
final long ts = prefs.getLong(DeviceSettingsPreferenceConst.PREF_AGPS_UPDATE_TIME, 0L);
|
||||
if (ts > 0) {
|
||||
prefAgpsUpdateTime.setSummary(sdf.format(new Date(ts)));
|
||||
} else {
|
||||
prefAgpsUpdateTime.setSummary(handler.getContext().getString(R.string.unknown));
|
||||
}
|
||||
}
|
||||
|
||||
final Preference prefAgpsStatus = handler.findPreference(DeviceSettingsPreferenceConst.PREF_AGPS_STATUS);
|
||||
if (prefAgpsStatus != null) {
|
||||
final GarminAgpsStatus agpsStatus = GarminAgpsStatus.valueOf(prefs.getString(DeviceSettingsPreferenceConst.PREF_AGPS_STATUS, GarminAgpsStatus.MISSING.name()));
|
||||
prefAgpsStatus.setSummary(handler.getContext().getString(agpsStatus.getText()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getPreferenceKeysWithSummary() {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
|
||||
public static final Creator<GarminSettingsCustomizer> CREATOR = new Creator<GarminSettingsCustomizer>() {
|
||||
@Override
|
||||
public GarminSettingsCustomizer createFromParcel(final Parcel in) {
|
||||
return new GarminSettingsCustomizer();
|
||||
}
|
||||
|
||||
@Override
|
||||
public GarminSettingsCustomizer[] newArray(final int size) {
|
||||
return new GarminSettingsCustomizer[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(@NonNull Parcel dest, int flags) {
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.devices.garmin.instinct2s;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.R;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
|
||||
|
||||
public class GarminInstinct2SCoordinator extends GarminCoordinator {
|
||||
@Override
|
||||
protected Pattern getSupportedDeviceName() {
|
||||
return Pattern.compile("Instinct 2S");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getDeviceNameResource() {
|
||||
return R.string.devicetype_garmin_instinct_2s;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsFlashing() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsAgpsUpdates() {
|
||||
return true;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.devices.garmin.instinct2solar;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.R;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class GarminInstinct2SolarCoordinator extends GarminCoordinator {
|
||||
@Override
|
||||
protected Pattern getSupportedDeviceName() {
|
||||
return Pattern.compile("Instinct 2 Solar");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getDeviceNameResource() {
|
||||
return R.string.devicetype_garmin_instinct_2_solar;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsFlashing() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsAgpsUpdates() {
|
||||
return true;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.devices.garmin.instinct2soltac;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.R;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
|
||||
|
||||
public class GarminInstinct2SolTacCoordinator extends GarminCoordinator {
|
||||
@Override
|
||||
protected Pattern getSupportedDeviceName() {
|
||||
return Pattern.compile("Instinct 2 SolTac");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getDeviceNameResource() {
|
||||
return R.string.devicetype_garmin_instinct_2_soltac;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsFlashing() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsAgpsUpdates() {
|
||||
return true;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.devices.garmin.instinctcrossover;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.R;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public class GarminInstinctCrossoverCoordinator extends GarminCoordinator {
|
||||
@Override
|
||||
protected Pattern getSupportedDeviceName() {
|
||||
return Pattern.compile("Instinct Crossover");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getDeviceNameResource() {
|
||||
return R.string.devicetype_garmin_instinct_crossover;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.devices.garmin.instinctsolar;
|
||||
|
||||
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 GarminInstinctSolarCoordinator extends GarminCoordinator {
|
||||
@Override
|
||||
protected Pattern getSupportedDeviceName() {
|
||||
return Pattern.compile("Instinct Solar");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCannedRepliesSlotCount(final GBDevice device) {
|
||||
return 16;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getDeviceNameResource() {
|
||||
return R.string.devicetype_garmin_instinct_solar;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
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;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsFlashing() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsAgpsUpdates() {
|
||||
return true;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -66,6 +66,7 @@ public final class HuaweiConstants {
|
|||
public static final String HU_WATCHGT3PRO_NAME = "huawei watch gt 3 pro-";
|
||||
public static final String HU_WATCHGT4_NAME = "huawei watch gt 4-";
|
||||
public static final String HU_WATCHFIT_NAME = "huawei watch fit-";
|
||||
public static final String HU_WATCHFIT2_NAME = "huawei watch fit 2-";
|
||||
public static final String HU_WATCHULTIMATE_NAME = "huawei watch ultimate-";
|
||||
|
||||
public static final String PREF_HUAWEI_ADDRESS = "huawei_address";
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
/* Copyright (C) 2024 Damien Gaignon
|
||||
|
||||
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.devices.huawei.huaweiwatchfit2;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.R;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiBRCoordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
|
||||
|
||||
public class HuaweiWatchFit2Coordinator extends HuaweiBRCoordinator {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(HuaweiWatchFit2Coordinator.class);
|
||||
|
||||
public HuaweiWatchFit2Coordinator() {
|
||||
super();
|
||||
getHuaweiCoordinator().setTransactionCrypted(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DeviceType getDeviceType() {
|
||||
return DeviceType.HUAWEIWATCHFIT2;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Pattern getSupportedDeviceName() {
|
||||
return Pattern.compile("(" + HuaweiConstants.HU_WATCHFIT2_NAME + ").*", Pattern.CASE_INSENSITIVE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getDeviceNameResource() {
|
||||
return R.string.devicetype_huawei_watchfit2;
|
||||
}
|
||||
}
|
|
@ -239,12 +239,17 @@ public class Workout {
|
|||
public byte swolf = -1;
|
||||
public short strokeRate = -1;
|
||||
|
||||
public short calories = -1;
|
||||
public short cyclingPower = -1;
|
||||
public short frequency = -1;
|
||||
public Integer altitude = null;
|
||||
|
||||
public int timestamp = -1; // Calculated timestamp for this data point
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Data{" +
|
||||
"unknownData=" + unknownData +
|
||||
"unknownData=" + Arrays.toString(unknownData) +
|
||||
", heartRate=" + heartRate +
|
||||
", speed=" + speed +
|
||||
", stepRate=" + stepRate +
|
||||
|
@ -259,13 +264,17 @@ public class Workout {
|
|||
", eversionAngle=" + eversionAngle +
|
||||
", swolf=" + swolf +
|
||||
", strokeRate=" + strokeRate +
|
||||
", calories=" + calories +
|
||||
", cyclingPower=" + cyclingPower +
|
||||
", frequency=" + frequency +
|
||||
", altitude=" + altitude +
|
||||
", timestamp=" + timestamp +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
|
||||
// I'm not sure about the lengths, but we haven't gotten any complaints so they probably are fine
|
||||
private final byte[] bitmapLengths = {1, 2, 1, 2, 2, 4, -1, 2, 2, 1, 1, 1, 1, 1, 1, 1};
|
||||
private final byte[] bitmapLengths = {1, 2, 1, 2, 2, 4, -1, 2, 2, 2, 1, 1, 1, 1, 1, 1};
|
||||
private final byte[] innerBitmapLengths = {2, 2, 2, 1, 2, 1, 1, 1, 1, 2, 2, 1, 1, 1, 1, 1};
|
||||
|
||||
public short workoutNumber;
|
||||
|
@ -367,6 +376,9 @@ public class Workout {
|
|||
case 4:
|
||||
data.strokeRate = buf.getShort();
|
||||
break;
|
||||
case 5:
|
||||
data.altitude = buf.getInt();
|
||||
break;
|
||||
case 6:
|
||||
// Inner data, parsing into data
|
||||
// TODO: function for readability?
|
||||
|
@ -410,6 +422,15 @@ public class Workout {
|
|||
}
|
||||
}
|
||||
break;
|
||||
case 7:
|
||||
data.calories = buf.getShort();
|
||||
break;
|
||||
case 8:
|
||||
data.frequency = buf.getShort();
|
||||
break;
|
||||
case 9:
|
||||
data.cyclingPower = buf.getShort();
|
||||
break;
|
||||
default:
|
||||
data.unknownData = this.tlv.serialize();
|
||||
// Fix alignment
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -106,6 +106,10 @@ public class ActivitySummaryEntries {
|
|||
public static final String MAXIMUM_OXYGEN_UPTAKE = "maximumOxygenUptake";
|
||||
public static final String RECOVERY_TIME = "recoveryTime";
|
||||
|
||||
public static final String CYCLING_POWER_AVERAGE = "cyclingPowerAverage";
|
||||
public static final String CYCLING_POWER_MIN = "cyclingPowerMin";
|
||||
public static final String CYCLING_POWER_MAX = "cyclingPowerMax";
|
||||
|
||||
public static final String UNIT_BPM = "bpm";
|
||||
public static final String UNIT_CM = "cm";
|
||||
public static final String UNIT_UNIX_EPOCH_SECONDS = "unix_epoch_seconds";
|
||||
|
|
|
@ -49,6 +49,16 @@ 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.instinctsolar.GarminInstinctSolarCoordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.instinct2s.GarminInstinct2SCoordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.instinct2solar.GarminInstinct2SolarCoordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.instinct2soltac.GarminInstinct2SolTacCoordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.instinctcrossover.GarminInstinctCrossoverCoordinator;
|
||||
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;
|
||||
|
@ -119,6 +129,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiband8.HuaweiBan
|
|||
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweibandaw70.HuaweiBandAw70Coordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweitalkbandb6.HuaweiTalkBandB6Coordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiwatchfit.HuaweiWatchFitCoordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiwatchfit2.HuaweiWatchFit2Coordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiwatchgt.HuaweiWatchGTCoordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiwatchgt2.HuaweiWatchGT2Coordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.huaweiwatchgt2e.HuaweiWatchGT2eCoordinator;
|
||||
|
@ -136,9 +147,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 +171,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,12 +192,14 @@ 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;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmiwatch2.RedmiWatch2Coordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmiwatch2lite.RedmiWatch2LiteCoordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmiwatch3.RedmiWatch3Coordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmiwatch3active.RedmiWatch3ActiveCoordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmiwatch4.RedmiWatch4Coordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.watchs1.XiaomiWatchS1Coordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.watchs1active.XiaomiWatchS1ActiveCoordinator;
|
||||
|
@ -194,8 +207,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;
|
||||
|
||||
/**
|
||||
* For every supported device, a device type constant must exist.
|
||||
|
@ -321,6 +332,16 @@ public enum DeviceType {
|
|||
ITAG(ITagCoordinator.class),
|
||||
NUTMINI(NutCoordinator.class),
|
||||
VIVOMOVE_HR(VivomoveHrCoordinator.class),
|
||||
GARMIN_FORERUNNER_245(GarminForerunner245Coordinator.class),
|
||||
GARMIN_INSTINCT_SOLAR(GarminInstinctSolarCoordinator.class),
|
||||
GARMIN_INSTINCT_2S(GarminInstinct2SCoordinator.class),
|
||||
GARMIN_INSTINCT_2_SOLAR(GarminInstinct2SolarCoordinator.class),
|
||||
GARMIN_INSTINCT_2_SOLTAC(GarminInstinct2SolTacCoordinator.class),
|
||||
GARMIN_INSTINCT_CROSSOVER(GarminInstinctCrossoverCoordinator.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),
|
||||
|
@ -366,6 +387,7 @@ public enum DeviceType {
|
|||
HUAWEIWATCHGT4(HuaweiWatchGT4Coordinator.class),
|
||||
HUAWEIBAND8(HuaweiBand8Coordinator.class),
|
||||
HUAWEIWATCHFIT(HuaweiWatchFitCoordinator.class),
|
||||
HUAWEIWATCHFIT2(HuaweiWatchFit2Coordinator.class),
|
||||
HUAWEIWATCHULTIMATE(HuaweiWatchUltimateCoordinator.class),
|
||||
VESC(VescCoordinator.class),
|
||||
BINARY_SENSOR(BinarySensorCoordinator.class),
|
||||
|
|
|
@ -745,7 +745,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()));
|
||||
}
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.service.btle;
|
||||
|
||||
import android.Manifest;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.Service;
|
||||
|
@ -326,8 +327,8 @@ public class BLEScanService extends Service {
|
|||
unregisterReceiver(bluetoothStateChangedReceiver);
|
||||
}
|
||||
|
||||
private boolean hasBluetoothPermission(){
|
||||
if(Build.VERSION.SDK_INT <= Build.VERSION_CODES.R){
|
||||
private boolean hasBluetoothPermission() {
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
|
||||
// workaround. Cannot give bluetooth permission on Android O
|
||||
LOG.warn("Running on android 11, skipping bluetooth permission check");
|
||||
return ActivityCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED;
|
||||
|
@ -335,6 +336,7 @@ public class BLEScanService extends Service {
|
|||
return ActivityCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED;
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission") // linter does not recognize the usage of hasBluetoothPermission
|
||||
private void restartScan(boolean applyFilters) {
|
||||
if (!hasBluetoothPermission()) {
|
||||
// this should never happen
|
||||
|
@ -357,7 +359,9 @@ public class BLEScanService extends Service {
|
|||
return;
|
||||
}
|
||||
if (currentState.isDoingAnyScan()) {
|
||||
scanner.stopScan(scanCallback);
|
||||
if (hasBluetoothPermission()) {
|
||||
scanner.stopScan(scanCallback);
|
||||
}
|
||||
}
|
||||
ArrayList<ScanFilter> scanFilters = null;
|
||||
|
||||
|
@ -375,7 +379,7 @@ public class BLEScanService extends Service {
|
|||
}
|
||||
}
|
||||
|
||||
if (scanFilters.size() == 0) {
|
||||
if (scanFilters.isEmpty()) {
|
||||
// no need to start scanning
|
||||
LOG.debug("restartScan: stopping BLE scan, no devices");
|
||||
currentState = ScanningState.NOT_SCANNING;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,364 @@
|
|||
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() + "_" + dateString + "_" + getFileIndex() + (getFiletype().isFitFile() ? ".fit" : ".bin");
|
||||
}
|
||||
|
||||
public String getLegacyFileName() {
|
||||
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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,695 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin;
|
||||
|
||||
import android.bluetooth.BluetoothGatt;
|
||||
import android.bluetooth.BluetoothGattCharacteristic;
|
||||
import android.location.Location;
|
||||
import android.net.Uri;
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
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.activities.devicesettings.DeviceSettingsPreferenceConst;
|
||||
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
|
||||
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminAgpsInstallHandler;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminPreferences;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.GarminCapability;
|
||||
import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.GBLocationService;
|
||||
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.GdiCore;
|
||||
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.agps.GarminAgpsStatus;
|
||||
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.service.devices.garmin.messages.status.NotificationSubscriptionStatusMessage;
|
||||
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;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SEND_APP_NOTIFICATIONS;
|
||||
|
||||
|
||||
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() {
|
||||
LOG.info("Garmin dispose()");
|
||||
GBLocationService.stop(getContext(), getDevice());
|
||||
stopMusicTimer();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
final NotificationSubscriptionStatusMessage.NotificationStatus finalStatus;
|
||||
if (getDevicePrefs().getBoolean(PREF_SEND_APP_NOTIFICATIONS, true)) {
|
||||
finalStatus = NotificationSubscriptionStatusMessage.NotificationStatus.ENABLED;
|
||||
} else {
|
||||
finalStatus = NotificationSubscriptionStatusMessage.NotificationStatus.DISABLED;
|
||||
}
|
||||
|
||||
LOG.info("NOTIFICATIONS ARE NOW enabled={}, status={}", enable, finalStatus);
|
||||
|
||||
sendOutgoingMessage(new NotificationSubscriptionStatusMessage(
|
||||
GFDIMessage.Status.ACK,
|
||||
finalStatus,
|
||||
enable,
|
||||
0
|
||||
));
|
||||
} 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 (!getKeepActivityDataOnDevice()) // delete file from watch upon successful download
|
||||
sendOutgoingMessage(new SetFileFlagsMessage(((FileDownloadedDeviceEvent) deviceEvent).directoryEntry.getFileIndex(), SetFileFlagsMessage.FileFlags.ARCHIVE));
|
||||
}
|
||||
|
||||
super.evaluateGBDeviceEvent(deviceEvent);
|
||||
}
|
||||
|
||||
private boolean getKeepActivityDataOnDevice() {
|
||||
return getDevicePrefs().getBoolean("keep_activity_data_on_device", true); // TODO: change to default false once we are sure of the consequences
|
||||
}
|
||||
|
||||
@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(final 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<>();
|
||||
|
||||
final RecordDefinition recordDefinitionToday = PredefinedLocalMessage.TODAY_WEATHER_CONDITIONS.getRecordDefinition();
|
||||
final RecordDefinition recordDefinitionHourly = PredefinedLocalMessage.HOURLY_WEATHER_FORECAST.getRecordDefinition();
|
||||
final RecordDefinition recordDefinitionDaily = PredefinedLocalMessage.DAILY_WEATHER_FORECAST.getRecordDefinition();
|
||||
|
||||
List<RecordDefinition> weatherDefinitions = new ArrayList<>(3);
|
||||
weatherDefinitions.add(recordDefinitionToday);
|
||||
weatherDefinitions.add(recordDefinitionHourly);
|
||||
weatherDefinitions.add(recordDefinitionDaily);
|
||||
|
||||
sendOutgoingMessage(new nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.FitDefinitionMessage(weatherDefinitions));
|
||||
|
||||
try {
|
||||
RecordData today = new RecordData(recordDefinitionToday, recordDefinitionToday.getRecordHeader());
|
||||
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(recordDefinitionHourly, recordDefinitionHourly.getRecordHeader());
|
||||
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(recordDefinitionDaily, recordDefinitionDaily.getRecordHeader());
|
||||
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(recordDefinitionDaily, recordDefinitionDaily.getRecordHeader());
|
||||
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) {
|
||||
switch (config) {
|
||||
case PREF_GARMIN_DEFAULT_REPLY_SUFFIX:
|
||||
sendOutgoingMessage(toggleDefaultReplySuffix(getDevicePrefs().getBoolean(PREF_GARMIN_DEFAULT_REPLY_SUFFIX, true)));
|
||||
break;
|
||||
case PREF_SEND_APP_NOTIFICATIONS:
|
||||
NotificationSubscriptionDeviceEvent notificationSubscriptionDeviceEvent = new NotificationSubscriptionDeviceEvent();
|
||||
notificationSubscriptionDeviceEvent.enable = true; // actual status is fetched from preferences
|
||||
evaluateGBDeviceEvent(notificationSubscriptionDeviceEvent);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void processDownloadQueue() {
|
||||
|
||||
moveFilesFromLegacyCache(); //TODO: remove before merging
|
||||
|
||||
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()) || checkFileExists(directoryEntry.getLegacyFileName())) {
|
||||
LOG.debug("File: {} already downloaded, not downloading again.", directoryEntry.getFileName());
|
||||
if (!getKeepActivityDataOnDevice()) // delete file from watch if already downloaded
|
||||
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 moveFilesFromLegacyCache() { //TODO: remove before merging
|
||||
File legacyDir;
|
||||
try {
|
||||
legacyDir = new File(FileUtils.getExternalFilesDir() + "/" + FileUtils.makeValidFileName(getDevice().getName() + "_" + getDevice().getAddress()));
|
||||
|
||||
if (legacyDir.isDirectory()) {
|
||||
final File newDir = getWritableExportDirectory();
|
||||
File[] files = legacyDir.listFiles();
|
||||
|
||||
for (File file : files) {
|
||||
if (file.isFile()) {
|
||||
File destFile = new File(newDir, file.getName());
|
||||
boolean success = file.renameTo(destFile);
|
||||
if (!success) {
|
||||
LOG.error("Failed to move file {}", file.getName());
|
||||
} else {
|
||||
LOG.info("Moved file {} to new cache directory", file.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean removed = legacyDir.delete();
|
||||
if (!removed) {
|
||||
LOG.error("Failed to remove legacy directory: {}", legacyDir);
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.error(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onInstallApp(final Uri uri) {
|
||||
final GarminAgpsInstallHandler agpsHandler = new GarminAgpsInstallHandler(uri, getContext());
|
||||
if (agpsHandler.isValid()) {
|
||||
try {
|
||||
// Write the AGPS update to a temporary file in cache, so we can load it when requested
|
||||
final File agpsFile = getAgpsFile();
|
||||
try (FileOutputStream outputStream = new FileOutputStream(agpsFile)) {
|
||||
outputStream.write(agpsHandler.getFile().getBytes());
|
||||
evaluateGBDeviceEvent(new GBDeviceEventUpdatePreferences(
|
||||
DeviceSettingsPreferenceConst.PREF_AGPS_STATUS, GarminAgpsStatus.PENDING.name()
|
||||
));
|
||||
LOG.info("AGPS file successfully written to the cache directory.");
|
||||
} catch (final IOException e) {
|
||||
LOG.error("Failed to write AGPS bytes to temporary directory", e);
|
||||
}
|
||||
} catch (final Exception e) {
|
||||
GB.toast(getContext(), "AGPS install error: " + e.getMessage(), Toast.LENGTH_LONG, GB.ERROR, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return getDevice().getDeviceCoordinator().getWritableExportDirectory(getDevice());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSetGpsLocation(final Location location) {
|
||||
final GdiCore.CoreService.LocationUpdatedNotification.Builder locationUpdatedNotification = GdiCore.CoreService.LocationUpdatedNotification.newBuilder()
|
||||
.addLocationData(
|
||||
GarminUtils.toLocationData(location, GdiCore.CoreService.DataType.REALTIME_TRACKING)
|
||||
);
|
||||
|
||||
final ProtobufMessage locationUpdatedNotificationRequest = protocolBufferHandler.prepareProtobufRequest(
|
||||
GdiSmartProto.Smart.newBuilder().setCoreService(
|
||||
GdiCore.CoreService.newBuilder().setLocationUpdatedNotification(locationUpdatedNotification)
|
||||
).build()
|
||||
);
|
||||
sendOutgoingMessage(locationUpdatedNotificationRequest);
|
||||
}
|
||||
|
||||
public File getAgpsFile() throws IOException {
|
||||
return new File(getAgpsCacheDirectory(), "CPE.BIN");
|
||||
}
|
||||
|
||||
private File getAgpsCacheDirectory() throws IOException {
|
||||
final File cacheDir = getContext().getCacheDir();
|
||||
final File agpsCacheDir = new File(cacheDir, "garmin-agps");
|
||||
if (agpsCacheDir.mkdir()) {
|
||||
LOG.info("AGPS cache directory for Garmin devices successfully created.");
|
||||
} else if (!agpsCacheDir.exists() || !agpsCacheDir.isDirectory()) {
|
||||
throw new IOException("Cannot create/locate AGPS directory for Garmin devices.");
|
||||
}
|
||||
return agpsCacheDir;
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin;
|
||||
|
||||
import android.location.Location;
|
||||
import android.os.Build;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiCore;
|
||||
|
||||
public final class GarminUtils {
|
||||
private GarminUtils() {
|
||||
// utility class
|
||||
}
|
||||
|
||||
public static GdiCore.CoreService.LocationData toLocationData(final Location location, final GdiCore.CoreService.DataType dataType) {
|
||||
final GdiCore.CoreService.LatLon positionForWatch = GdiCore.CoreService.LatLon.newBuilder()
|
||||
.setLat((int) ((location.getLatitude() * 2.147483648E9d) / 180.0d))
|
||||
.setLon((int) ((location.getLongitude() * 2.147483648E9d) / 180.0d))
|
||||
.build();
|
||||
|
||||
float vAccuracy = 0;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
vAccuracy = location.getVerticalAccuracyMeters();
|
||||
}
|
||||
|
||||
return GdiCore.CoreService.LocationData.newBuilder()
|
||||
.setPosition(positionForWatch)
|
||||
.setAltitude((float) location.getAltitude())
|
||||
.setTimestamp(GarminTimeUtils.javaMillisToGarminTimestamp(location.getTime()))
|
||||
.setHAccuracy(location.getAccuracy())
|
||||
.setVAccuracy(vAccuracy)
|
||||
.setPositionType(dataType)
|
||||
.setBearing(location.getBearing())
|
||||
.setSpeed(location.getSpeed())
|
||||
.build();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -0,0 +1,507 @@
|
|||
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), GB.hexdump(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;
|
||||
|
||||
// add an empty bogus action to toggle the hasActions boolean. The actions are hardcoded on the watch in case of incoming calls.
|
||||
callNotificationSpec.attachedActions = new ArrayList<>();
|
||||
callNotificationSpec.attachedActions.add(0, new NotificationSpec.Action());
|
||||
|
||||
|
||||
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:
|
||||
deviceEvtCallControl.event = GBDeviceEventCallControl.Event.REJECT;
|
||||
message.addGbDeviceEvent(deviceEvtCallControl);
|
||||
case REPLY_MESSAGES:
|
||||
deviceEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.REPLY;
|
||||
deviceEvtNotificationControl.reply = message.getActionString();
|
||||
if (notificationSpec.type.equals(NotificationType.GENERIC_PHONE) || notificationSpec.type.equals(NotificationType.GENERIC_SMS)) {
|
||||
deviceEvtNotificationControl.phoneNumber = notificationSpec.phoneNumber;
|
||||
} else {
|
||||
deviceEvtNotificationControl.handle = mNotificationReplyAction.lookup(notificationSpec.getId()); //handle of wearable action is needed
|
||||
}
|
||||
message.addGbDeviceEvent(deviceEvtNotificationControl);
|
||||
break;
|
||||
case ACCEPT_INCOMING_CALL:
|
||||
deviceEvtCallControl.event = GBDeviceEventCallControl.Event.ACCEPT;
|
||||
message.addGbDeviceEvent(deviceEvtCallControl);
|
||||
break;
|
||||
case REJECT_INCOMING_CALL:
|
||||
deviceEvtCallControl.event = GBDeviceEventCallControl.Event.REJECT;
|
||||
message.addGbDeviceEvent(deviceEvtCallControl);
|
||||
break;
|
||||
case DISMISS_NOTIFICATION:
|
||||
deviceEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.DISMISS;
|
||||
message.addGbDeviceEvent(deviceEvtNotificationControl);
|
||||
break;
|
||||
case BLOCK_APPLICATION:
|
||||
deviceEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.MUTE;
|
||||
message.addGbDeviceEvent(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:
|
||||
if (NotificationType.GENERIC_SMS.equals(notificationSpec.type))
|
||||
toReturn = notificationSpec.sender == null ? "" : notificationSpec.sender;
|
||||
else
|
||||
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:
|
||||
case NotificationSpec.Action.TYPE_SYNTECTIC_REPLY_PHONENR:
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,487 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin;
|
||||
|
||||
import android.location.Location;
|
||||
|
||||
import com.google.protobuf.InvalidProtocolBufferException;
|
||||
|
||||
import org.apache.commons.lang3.ArrayUtils;
|
||||
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 java.util.Objects;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
|
||||
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo;
|
||||
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminPreferences;
|
||||
import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.GBLocationProviderType;
|
||||
import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.GBLocationService;
|
||||
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.service.devices.pebble.webview.CurrentPosition;
|
||||
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 final Map<GdiSmsNotification.SmsNotificationService.CannedListType, String[]> cannedListTypeMap = new HashMap<>();
|
||||
|
||||
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???
|
||||
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;
|
||||
}
|
||||
if (!calendarServiceRequest.getIncludeAllDay() && mEvt.isAllDay()) {
|
||||
LOG.debug("CalendarService Skipping event {} that is AllDay", mEvt.getTitle());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (watchEvents.size() >= calendarServiceRequest.getMaxEvents() * 2) { //NOTE: Tested with values higher than double of the reported max without issues
|
||||
LOG.debug("Reached the maximum number of events supported by the watch");
|
||||
break;
|
||||
}
|
||||
|
||||
final GdiCalendarService.CalendarService.CalendarEvent.Builder event = GdiCalendarService.CalendarService.CalendarEvent.newBuilder()
|
||||
.setTitle(mEvt.getTitle().substring(0, Math.min(mEvt.getTitle().length(), calendarServiceRequest.getMaxTitleLength())))
|
||||
.setAllDay(mEvt.isAllDay())
|
||||
.setStartDate(mEvt.getBeginSeconds())
|
||||
.setEndDate(mEvt.getEndSeconds());
|
||||
|
||||
if (calendarServiceRequest.getIncludeLocation() && mEvt.getLocation() != null) {
|
||||
event.setLocation(mEvt.getLocation().substring(0, Math.min(mEvt.getLocation().length(), calendarServiceRequest.getMaxLocationLength())));
|
||||
}
|
||||
|
||||
if (calendarServiceRequest.getIncludeDescription() && mEvt.getDescription() != null) {
|
||||
event.setDescription(mEvt.getDescription().substring(0, Math.min(mEvt.getDescription().length(), calendarServiceRequest.getMaxDescriptionLength())));
|
||||
}
|
||||
if (calendarServiceRequest.getIncludeOrganizer() && mEvt.getOrganizer() != null) {
|
||||
event.setDescription(mEvt.getOrganizer().substring(0, Math.min(mEvt.getOrganizer().length(), calendarServiceRequest.getMaxOrganizerLength())));
|
||||
}
|
||||
watchEvents.add(event.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)
|
||||
.setStatus(GdiCalendarService.CalendarService.CalendarServiceResponse.ResponseStatus.OK)
|
||||
)
|
||||
).build();
|
||||
}
|
||||
LOG.warn("Unknown CalendarService request: {}", calendarService);
|
||||
return GdiSmartProto.Smart.newBuilder().setCalendarService(
|
||||
GdiCalendarService.CalendarService.newBuilder().setCalendarResponse(
|
||||
GdiCalendarService.CalendarService.CalendarServiceResponse.newBuilder()
|
||||
.setStatus(GdiCalendarService.CalendarService.CalendarServiceResponse.ResponseStatus.UNKNOWN_RESPONSE_STATUS)
|
||||
)
|
||||
).build();
|
||||
}
|
||||
|
||||
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.hasSyncResponse()) {
|
||||
final GdiCore.CoreService.SyncResponse syncResponse = coreService.getSyncResponse();
|
||||
LOG.info("Received sync status: {}", syncResponse.getStatus());
|
||||
return null;
|
||||
}
|
||||
|
||||
if (coreService.hasGetLocationRequest()) {
|
||||
LOG.info("Got location request");
|
||||
final Location location = new CurrentPosition().getLastKnownLocation();
|
||||
final GdiCore.CoreService.GetLocationResponse.Builder response = GdiCore.CoreService.GetLocationResponse.newBuilder();
|
||||
if (location.getLatitude() == 0 && location.getLongitude() == 0) {
|
||||
response.setStatus(GdiCore.CoreService.GetLocationResponse.Status.NO_VALID_LOCATION);
|
||||
} else {
|
||||
response.setStatus(GdiCore.CoreService.GetLocationResponse.Status.OK)
|
||||
.setLocationData(GarminUtils.toLocationData(location, GdiCore.CoreService.DataType.GENERAL_LOCATION));
|
||||
}
|
||||
return GdiSmartProto.Smart.newBuilder().setCoreService(
|
||||
GdiCore.CoreService.newBuilder().setGetLocationResponse(response)).build();
|
||||
}
|
||||
|
||||
if (coreService.hasLocationUpdatedSetEnabledRequest()) {
|
||||
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);
|
||||
|
||||
final boolean sendGpsPref = deviceSupport.getDevicePrefs().getBoolean(DeviceSettingsPreferenceConst.PREF_WORKOUT_SEND_GPS_TO_BAND, false);
|
||||
|
||||
GdiCore.CoreService.Request realtimeRequest = null;
|
||||
|
||||
if (locationUpdatedSetEnabledRequest.getEnabled()) {
|
||||
for (final GdiCore.CoreService.Request request : locationUpdatedSetEnabledRequest.getRequestsList()) {
|
||||
final GdiCore.CoreService.LocationUpdatedSetEnabledResponse.Requested.RequestedStatus requestedStatus;
|
||||
if (GdiCore.CoreService.DataType.REALTIME_TRACKING.equals(request.getRequested())) {
|
||||
realtimeRequest = request;
|
||||
if (sendGpsPref) {
|
||||
requestedStatus = GdiCore.CoreService.LocationUpdatedSetEnabledResponse.Requested.RequestedStatus.OK;
|
||||
} else {
|
||||
requestedStatus = GdiCore.CoreService.LocationUpdatedSetEnabledResponse.Requested.RequestedStatus.KO;
|
||||
}
|
||||
} else {
|
||||
requestedStatus = GdiCore.CoreService.LocationUpdatedSetEnabledResponse.Requested.RequestedStatus.KO;
|
||||
}
|
||||
|
||||
response.addRequests(
|
||||
GdiCore.CoreService.LocationUpdatedSetEnabledResponse.Requested.newBuilder()
|
||||
.setRequested(request.getRequested())
|
||||
.setStatus(requestedStatus)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (sendGpsPref) {
|
||||
if (realtimeRequest != null) {
|
||||
GBLocationService.start(
|
||||
deviceSupport.getContext(),
|
||||
deviceSupport.getDevice(),
|
||||
GBLocationProviderType.GPS,
|
||||
1000 // TODO from realtimeRequest
|
||||
);
|
||||
} else {
|
||||
GBLocationService.stop(deviceSupport.getContext(), deviceSupport.getDevice());
|
||||
}
|
||||
}
|
||||
|
||||
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()) {
|
||||
LOG.debug("Got request for sms canned list");
|
||||
|
||||
// Mark canned messages as supported
|
||||
deviceSupport.evaluateGBDeviceEvent(new GBDeviceEventUpdatePreferences(GarminPreferences.PREF_FEAT_CANNED_MESSAGES, true));
|
||||
|
||||
if (this.cannedListTypeMap.isEmpty()) {
|
||||
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(Objects.requireNonNull(this.cannedListTypeMap.get(requestedType))))
|
||||
.setType(requestedType)
|
||||
);
|
||||
} else {
|
||||
builder.setStatus(GdiSmsNotification.SmsNotificationService.ResponseStatus.GENERIC_ERROR);
|
||||
LOG.info("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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.agps;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.threeten.bp.Instant;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Callable;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
|
||||
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupport;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.GBTarFile;
|
||||
|
||||
public class AgpsHandler {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(AgpsHandler.class);
|
||||
private static final String QUERY_CONSTELLATIONS = "constellations";
|
||||
private final GarminSupport deviceSupport;
|
||||
|
||||
public AgpsHandler(GarminSupport deviceSupport) {
|
||||
this.deviceSupport = deviceSupport;
|
||||
}
|
||||
|
||||
public byte[] handleAgpsRequest(final String path, final Map<String, String> query) {
|
||||
try {
|
||||
if (!query.containsKey(QUERY_CONSTELLATIONS)) {
|
||||
LOG.debug("Query does not contain information about constellations; skipping request.");
|
||||
return null;
|
||||
}
|
||||
final File agpsFile = deviceSupport.getAgpsFile();
|
||||
if (!agpsFile.exists() || !agpsFile.isFile()) {
|
||||
LOG.info("File with AGPS data does not exist.");
|
||||
return null;
|
||||
}
|
||||
try(InputStream agpsIn = new FileInputStream(agpsFile)) {
|
||||
final byte[] rawBytes = FileUtils.readAll(agpsIn, 1024 * 1024); // 1MB, they're usually ~60KB
|
||||
final GBTarFile tarFile = new GBTarFile(rawBytes);
|
||||
final String[] requestedConstellations = Objects.requireNonNull(query.get(QUERY_CONSTELLATIONS)).split(",");
|
||||
for (final String constellation: requestedConstellations) {
|
||||
try {
|
||||
final GarminAgpsDataType garminAgpsDataType = GarminAgpsDataType.valueOf(constellation);
|
||||
if (!tarFile.containsFile(garminAgpsDataType.getFileName())) {
|
||||
LOG.error("AGPS archive is missing requested file: {}", garminAgpsDataType.getFileName());
|
||||
deviceSupport.evaluateGBDeviceEvent(new GBDeviceEventUpdatePreferences(
|
||||
DeviceSettingsPreferenceConst.PREF_AGPS_STATUS, GarminAgpsStatus.ERROR.name()
|
||||
));
|
||||
return null;
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
LOG.error("Device requested unsupported AGPS data type: {}", constellation);
|
||||
deviceSupport.evaluateGBDeviceEvent(new GBDeviceEventUpdatePreferences(
|
||||
DeviceSettingsPreferenceConst.PREF_AGPS_STATUS, GarminAgpsStatus.ERROR.name()
|
||||
));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
LOG.info("Sending new AGPS data to the device.");
|
||||
return rawBytes;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.error("Unable to obtain AGPS data.", e);
|
||||
deviceSupport.evaluateGBDeviceEvent(new GBDeviceEventUpdatePreferences(
|
||||
DeviceSettingsPreferenceConst.PREF_AGPS_STATUS, GarminAgpsStatus.ERROR.name()
|
||||
));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public Callable<Void> getOnDataSuccessfullySentListener() {
|
||||
return () -> {
|
||||
LOG.info("AGPS data successfully sent to the device.");
|
||||
deviceSupport.evaluateGBDeviceEvent(new GBDeviceEventUpdatePreferences(
|
||||
DeviceSettingsPreferenceConst.PREF_AGPS_UPDATE_TIME, Instant.now().toEpochMilli()
|
||||
));
|
||||
deviceSupport.evaluateGBDeviceEvent(new GBDeviceEventUpdatePreferences(
|
||||
DeviceSettingsPreferenceConst.PREF_AGPS_STATUS, GarminAgpsStatus.CURRENT.name()
|
||||
));
|
||||
if (deviceSupport.getAgpsFile().delete()) {
|
||||
LOG.info("AGPS data was deleted from the cache folder.");
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.agps;
|
||||
|
||||
public enum GarminAgpsDataType {
|
||||
GLONASS("CPE_GLO.BIN"), QZSS("CPE_QZSS.BIN"), GPS("CPE_GPS.BIN"),
|
||||
GALILEO("CPE_GAL.BIN");
|
||||
|
||||
private final String fileName;
|
||||
|
||||
GarminAgpsDataType(String fileName) {
|
||||
this.fileName = fileName;
|
||||
}
|
||||
|
||||
public String getFileName() {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
public static boolean isValidAgpsDataFileName(String fileName) {
|
||||
for (GarminAgpsDataType type: GarminAgpsDataType.values()) {
|
||||
if (fileName.equals(type.fileName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.agps;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.GBTarFile;
|
||||
|
||||
|
||||
public class GarminAgpsFile {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(GarminAgpsFile.class);
|
||||
private final byte[] tarBytes;
|
||||
|
||||
public GarminAgpsFile(final byte[] tarBytes) {
|
||||
this.tarBytes = tarBytes;
|
||||
}
|
||||
|
||||
public boolean isValid() {
|
||||
if (!GBTarFile.isTarFile(tarBytes)) {
|
||||
LOG.debug("Is not TAR file!");
|
||||
return false;
|
||||
}
|
||||
|
||||
final GBTarFile tarFile = new GBTarFile(tarBytes);
|
||||
for (final String fileName: tarFile.listFileNames()) {
|
||||
if (!GarminAgpsDataType.isValidAgpsDataFileName(fileName)) {
|
||||
LOG.error("Unknown file in TAR archive: {}", fileName);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public byte[] getBytes() {
|
||||
return tarBytes.clone();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.agps;
|
||||
|
||||
import androidx.annotation.StringRes;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.R;
|
||||
|
||||
|
||||
public enum GarminAgpsStatus {
|
||||
MISSING(R.string.agps_status_missing), // AGPS data file was not yet installed
|
||||
PENDING(R.string.agps_status_pending), // AGPS data file is waiting for installation
|
||||
CURRENT(R.string.agps_status_current), // AGPS data was successfully installed
|
||||
ERROR(R.string.agps_status_error); // Unable to install AGPS data file
|
||||
|
||||
private final @StringRes int text;
|
||||
|
||||
GarminAgpsStatus(@StringRes int text) {
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
public @StringRes int getText() {
|
||||
return text;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
}
|
|
@ -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;
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,93 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
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 static final Logger LOG = LoggerFactory.getLogger(FieldDefinition.class);
|
||||
|
||||
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);
|
||||
FieldDefinition global = globalFITMessage.getFieldDefinition(number, size);
|
||||
if (global != null) {
|
||||
if (global.getBaseType().equals(baseType)) {
|
||||
return global;
|
||||
} else {
|
||||
LOG.warn("Global is of type {}, but message declares {}", global.getBaseType(), baseType);
|
||||
}
|
||||
}
|
||||
|
||||
if (number == 253 && size == 4 && baseType.equals(BaseType.UINT32)) {
|
||||
return new FieldDefinitionTimestamp(number, size, baseType, "253_timestamp");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
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.FieldDefinitionSleepStage;
|
||||
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, 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);
|
||||
case SLEEP_STAGE:
|
||||
return new FieldDefinitionSleepStage(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,
|
||||
SLEEP_STAGE,
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
||||
}
|
|
@ -0,0 +1,227 @@
|
|||
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.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.fit.messages.FitRecordDataFactory;
|
||||
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 List<RecordData> dataRecords;
|
||||
private final boolean canGenerateOutput;
|
||||
|
||||
public FitFile(Header header, List<RecordData> dataRecords) {
|
||||
this.header = header;
|
||||
this.dataRecords = dataRecords;
|
||||
this.canGenerateOutput = false;
|
||||
}
|
||||
|
||||
public FitFile(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);
|
||||
|
||||
// needed because the headers can be redefined in the file. The last header for a local message number wins
|
||||
Map<Integer, RecordDefinition> recordDefinitionMap = new HashMap<>();
|
||||
List<RecordData> dataRecords = new ArrayList<>();
|
||||
Long referenceTimestamp = null;
|
||||
|
||||
while (garminByteBufferReader.getPosition() < header.getHeaderSize() + header.getDataSize()) {
|
||||
byte rawRecordHeader = (byte) garminByteBufferReader.readByte();
|
||||
RecordHeader recordHeader = new RecordHeader(rawRecordHeader);
|
||||
final Integer timeOffset = recordHeader.getTimeOffset();
|
||||
if (timeOffset != null) {
|
||||
if (referenceTimestamp == null) {
|
||||
throw new IllegalArgumentException("Got compressed timestamp without knowing current timestamp");
|
||||
}
|
||||
|
||||
if (timeOffset >= (referenceTimestamp & 0x1FL)) {
|
||||
referenceTimestamp = (referenceTimestamp & ~0x1FL) + timeOffset;
|
||||
} else if (timeOffset < (referenceTimestamp & 0x1FL)) {
|
||||
referenceTimestamp = (referenceTimestamp & ~0x1FL) + timeOffset + 0x20;
|
||||
}
|
||||
}
|
||||
if (recordHeader.isDefinition()) {
|
||||
final RecordDefinition recordDefinition = RecordDefinition.parseIncoming(garminByteBufferReader, recordHeader);
|
||||
if (recordDefinition != null) {
|
||||
if (recordHeader.isDeveloperData())
|
||||
for (RecordData rd : dataRecords) {
|
||||
if (GlobalFITMessage.FIELD_DESCRIPTION.equals(rd.getGlobalFITMessage()))
|
||||
recordDefinition.populateDevFields(rd);
|
||||
}
|
||||
recordDefinitionMap.put(recordHeader.getLocalMessageType(), recordDefinition);
|
||||
}
|
||||
} else {
|
||||
final RecordDefinition referenceRecordDefinition = recordDefinitionMap.get(recordHeader.getLocalMessageType());
|
||||
if (referenceRecordDefinition != null) {
|
||||
final RecordData runningData = FitRecordDataFactory.create(referenceRecordDefinition, recordHeader);
|
||||
dataRecords.add(runningData);
|
||||
Long newTimestamp = runningData.parseDataMessage(garminByteBufferReader, referenceTimestamp);
|
||||
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 (RecordData rd : dataRecords) {
|
||||
if (globalFITMessage.equals(rd.getGlobalFITMessage()))
|
||||
filtered.add(rd);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
public List<RecordData> getRecords() {
|
||||
return dataRecords;
|
||||
}
|
||||
|
||||
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);
|
||||
RecordDefinition prevDefinition = null;
|
||||
for (final RecordData rd : dataRecords) {
|
||||
if (!rd.getRecordDefinition().equals(prevDefinition)) {
|
||||
rd.getRecordDefinition().generateOutgoingPayload(temporary);
|
||||
prevDefinition = rd.getRecordDefinition();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
public 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 != 0 && 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));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,342 @@
|
|||
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 DEVICE_INFO = new GlobalFITMessage(23, "DEVICE_INFO", Arrays.asList(
|
||||
new FieldDefinitionPrimitive(2, BaseType.UINT16, "manufacturer"),
|
||||
new FieldDefinitionPrimitive(3, BaseType.UINT32Z, "serial_number"),
|
||||
new FieldDefinitionPrimitive(4, BaseType.UINT16, "product"),
|
||||
new FieldDefinitionPrimitive(5, BaseType.UINT16, "software_version"),
|
||||
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 MONITORING = new GlobalFITMessage(55, "MONITORING", Arrays.asList(
|
||||
new FieldDefinitionPrimitive(2, BaseType.UINT32, "distance"),
|
||||
new FieldDefinitionPrimitive(3, BaseType.UINT32, "cycles"),
|
||||
new FieldDefinitionPrimitive(4, BaseType.UINT32, "active_time"),
|
||||
new FieldDefinitionPrimitive(5, BaseType.ENUM, "activity_type"),
|
||||
new FieldDefinitionPrimitive(19, BaseType.UINT16, "active_calories"),
|
||||
new FieldDefinitionPrimitive(29, BaseType.UINT16, "duration_min"),
|
||||
new FieldDefinitionPrimitive(24, BaseType.BASE_TYPE_BYTE, "current_activity_type_intensity"),
|
||||
new FieldDefinitionPrimitive(26, BaseType.UINT16, "timestamp_16"),
|
||||
new FieldDefinitionPrimitive(27, BaseType.UINT8, "heart_rate"),
|
||||
new FieldDefinitionPrimitive(253, BaseType.UINT32, "timestamp", FieldDefinitionFactory.FIELD.TIMESTAMP)
|
||||
));
|
||||
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 GlobalFITMessage STRESS_LEVEL = new GlobalFITMessage(227, "STRESS_LEVEL", Arrays.asList(
|
||||
new FieldDefinitionPrimitive(0, BaseType.SINT16, "stress_level_value"),
|
||||
new FieldDefinitionPrimitive(1, BaseType.UINT32, "stress_level_time", FieldDefinitionFactory.FIELD.TIMESTAMP)
|
||||
));
|
||||
|
||||
public static GlobalFITMessage SLEEP_STAGE = new GlobalFITMessage(275, "SLEEP_STAGE", Arrays.asList(
|
||||
new FieldDefinitionPrimitive(0, BaseType.ENUM, "sleep_stage", FieldDefinitionFactory.FIELD.SLEEP_STAGE),
|
||||
new FieldDefinitionPrimitive(253, BaseType.UINT32, "timestamp", FieldDefinitionFactory.FIELD.TIMESTAMP)
|
||||
));
|
||||
public static GlobalFITMessage SLEEP_STATS = new GlobalFITMessage(346, "SLEEP_STATS", Arrays.asList(
|
||||
));
|
||||
|
||||
public static Map<Integer, GlobalFITMessage> KNOWN_MESSAGES = 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(23, DEVICE_INFO);
|
||||
put(49, FILE_CREATOR);
|
||||
put(55, MONITORING);
|
||||
put(127, CONNECTIVITY);
|
||||
put(128, WEATHER);
|
||||
put(159, WATCHFACE_SETTINGS);
|
||||
put(206, FIELD_DESCRIPTION);
|
||||
put(207, DEVELOPER_DATA);
|
||||
put(222, ALARM_SETTINGS);
|
||||
put(227, STRESS_LEVEL);
|
||||
put(275, SLEEP_STAGE);
|
||||
put(346, SLEEP_STATS);
|
||||
}};
|
||||
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 = KNOWN_MESSAGES.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;
|
||||
}
|
||||
|
||||
public List<FieldDefinitionPrimitive> getFieldDefinitionPrimitives() {
|
||||
return fieldDefinitionPrimitives;
|
||||
}
|
||||
|
||||
@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(String name) {
|
||||
for (FieldDefinitionPrimitive fieldDefinitionPrimitive :
|
||||
fieldDefinitionPrimitives) {
|
||||
if (fieldDefinitionPrimitive.name.equals(name)) {
|
||||
return FieldDefinitionFactory.create(
|
||||
fieldDefinitionPrimitive.number,
|
||||
fieldDefinitionPrimitive.size,
|
||||
fieldDefinitionPrimitive.type,
|
||||
fieldDefinitionPrimitive.baseType,
|
||||
fieldDefinitionPrimitive.name,
|
||||
fieldDefinitionPrimitive.scale,
|
||||
fieldDefinitionPrimitive.offset
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
public 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);
|
||||
}
|
||||
|
||||
public int getNumber() {
|
||||
return number;
|
||||
}
|
||||
|
||||
public BaseType getBaseType() {
|
||||
return baseType;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public FieldDefinitionFactory.FIELD getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public int getScale() {
|
||||
return scale;
|
||||
}
|
||||
|
||||
public int getOffset() {
|
||||
return offset;
|
||||
}
|
||||
|
||||
public int getSize() {
|
||||
return size;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
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 RecordDefinition getRecordDefinition() {
|
||||
final RecordHeader recordHeader = new RecordHeader(true, false, type, null);
|
||||
final List<FieldDefinition> fieldDefinitions = globalFITMessage.getFieldDefinitions(globalDefinitionIds);
|
||||
return new RecordDefinition(
|
||||
recordHeader,
|
||||
ByteOrder.BIG_ENDIAN,
|
||||
globalFITMessage,
|
||||
fieldDefinitions,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
public int getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public GlobalFITMessage getGlobalFITMessage() {
|
||||
return globalFITMessage;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,285 @@
|
|||
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.Date;
|
||||
import java.util.List;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminByteBufferReader;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.MessageWriter;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.ArrayUtils;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.GBToStringBuilder;
|
||||
|
||||
import static nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType.STRING;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
public class RecordData {
|
||||
|
||||
private final RecordDefinition recordDefinition;
|
||||
private final RecordHeader recordHeader;
|
||||
private final GlobalFITMessage globalFITMessage;
|
||||
private final List<FieldData> fieldDataList;
|
||||
protected ByteBuffer valueHolder;
|
||||
|
||||
private Long computedTimestamp = null;
|
||||
|
||||
public RecordData(final RecordDefinition recordDefinition, final RecordHeader recordHeader) {
|
||||
if (null == recordDefinition.getFieldDefinitions())
|
||||
throw new IllegalArgumentException("Cannot create record data without FieldDefinitions " + recordDefinition);
|
||||
|
||||
fieldDataList = new ArrayList<>();
|
||||
|
||||
this.recordDefinition = recordDefinition;
|
||||
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 GlobalFITMessage getGlobalFITMessage() {
|
||||
return globalFITMessage;
|
||||
}
|
||||
|
||||
public RecordDefinition getRecordDefinition() {
|
||||
return recordDefinition;
|
||||
}
|
||||
|
||||
public Long parseDataMessage(final GarminByteBufferReader garminByteBufferReader, final Long currentTimestamp) {
|
||||
garminByteBufferReader.setByteOrder(valueHolder.order());
|
||||
computedTimestamp = currentTimestamp;
|
||||
Long referenceTimestamp = null;
|
||||
for (FieldData fieldData : fieldDataList) {
|
||||
Long runningTimestamp = fieldData.parseDataMessage(garminByteBufferReader);
|
||||
if (runningTimestamp != null) {
|
||||
computedTimestamp = runningTimestamp;
|
||||
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();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public Object getFieldByName(String name) {
|
||||
for (FieldData fieldData :
|
||||
fieldDataList) {
|
||||
if (fieldData.getName().equals(name)) {
|
||||
return fieldData.decode();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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() {
|
||||
return computedTimestamp;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String toString() {
|
||||
final GBToStringBuilder tsb = new GBToStringBuilder(this);
|
||||
|
||||
if (this.getClass().getName().equals(RecordData.class.getName())) {
|
||||
tsb.append(globalFITMessage.name());
|
||||
}
|
||||
|
||||
if (computedTimestamp != null) {
|
||||
tsb.append(new Date(computedTimestamp * 1000L));
|
||||
}
|
||||
|
||||
for (FieldData fieldData : fieldDataList) {
|
||||
final String fieldName;
|
||||
if (!StringUtils.isBlank(fieldData.getName())) {
|
||||
fieldName = fieldData.getName();
|
||||
} else {
|
||||
fieldName = "unknown_" + fieldData.getNumber() + fieldData;
|
||||
}
|
||||
Object o = fieldData.decode();
|
||||
final String fieldValueString;
|
||||
if (o == null) {
|
||||
fieldValueString = null;
|
||||
} else if (o instanceof Object[]) {
|
||||
fieldValueString = "[" + StringUtils.join((Object[]) o, ",") + "]";
|
||||
} else {
|
||||
fieldValueString = o.toString();
|
||||
}
|
||||
tsb.append(fieldName, fieldValueString);
|
||||
}
|
||||
return tsb.build();
|
||||
}
|
||||
|
||||
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.getNumber() == 253)
|
||||
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 + ")";
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,126 @@
|
|||
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 java.nio.ByteOrder byteOrder;
|
||||
private List<FieldDefinition> fieldDefinitions;
|
||||
private List<DevFieldDefinition> devFieldDefinitions;
|
||||
|
||||
public RecordDefinition(RecordHeader recordHeader, ByteOrder byteOrder, GlobalFITMessage globalFITMessage, List<FieldDefinition> fieldDefinitions, List<DevFieldDefinition> devFieldDefinitions) {
|
||||
this.recordHeader = recordHeader;
|
||||
this.byteOrder = byteOrder;
|
||||
this.globalFITMessage = globalFITMessage;
|
||||
this.fieldDefinitions = fieldDefinitions;
|
||||
this.devFieldDefinitions = devFieldDefinitions;
|
||||
}
|
||||
|
||||
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(recordHeader, byteOrder, globalFITMessage, null, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public String toString() {
|
||||
return System.lineSeparator() + recordHeader.toString() +
|
||||
" Global Message Number: " + globalFITMessage.name();
|
||||
}
|
||||
|
||||
public void populateDevFields(RecordData recordData) {
|
||||
for (DevFieldDefinition devFieldDef : getDevFieldDefinitions()) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,87 @@
|
|||
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 int localMessageType;
|
||||
private final Integer timeOffset;
|
||||
|
||||
public RecordHeader(boolean definition, boolean developerData, int localMessageType, Integer timeOffset) {
|
||||
this.definition = definition;
|
||||
this.developerData = developerData;
|
||||
this.localMessageType = localMessageType;
|
||||
this.timeOffset = timeOffset;
|
||||
}
|
||||
|
||||
//see https://github.com/polyvertex/fitdecode/blob/master/fitdecode/reader.py#L512
|
||||
public RecordHeader(byte header) {
|
||||
if ((header & 0x80) == 0x80) { //compressed timestamp
|
||||
definition = false;
|
||||
developerData = false;
|
||||
localMessageType = (header >> 5) & 0x3;
|
||||
timeOffset = header & 0x1f;
|
||||
} else {
|
||||
definition = ((header & 0x40) == 0x40);
|
||||
developerData = ((header & 0x20) == 0x20);
|
||||
localMessageType = header & 0xf;
|
||||
timeOffset = null;
|
||||
}
|
||||
}
|
||||
|
||||
public int getLocalMessageType() {
|
||||
return localMessageType;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getTimeOffset() {
|
||||
return timeOffset;
|
||||
}
|
||||
|
||||
public boolean isCompressedTimestamp() {
|
||||
return timeOffset != null;
|
||||
}
|
||||
|
||||
public boolean isDeveloperData() {
|
||||
return developerData;
|
||||
}
|
||||
|
||||
public boolean isDefinition() {
|
||||
return definition;
|
||||
}
|
||||
|
||||
public byte generateOutgoingDefinitionPayload() {
|
||||
if (!definition && !developerData) {
|
||||
assert timeOffset != null;
|
||||
return (byte) (timeOffset | (((byte) localMessageType) << 5));
|
||||
}
|
||||
byte base = (byte) localMessageType;
|
||||
if (definition)
|
||||
base = (byte) (base | 0x40);
|
||||
if (developerData)
|
||||
base = (byte) (base | 0x20);
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
public byte generateOutgoingDataPayload() { //TODO: unclear if correct
|
||||
if (!definition && !developerData) {
|
||||
assert timeOffset != null;
|
||||
return (byte) (timeOffset | (((byte) localMessageType) << 5));
|
||||
}
|
||||
byte base = (byte) localMessageType;
|
||||
if (developerData)
|
||||
base = (byte) (base | 0x20);
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Local Message: " + localMessageType;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,297 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.codegen;
|
||||
|
||||
import android.os.Build;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.RequiresApi;
|
||||
|
||||
import org.threeten.bp.DayOfWeek;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Calendar;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.GlobalFITMessage;
|
||||
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.fit.RecordHeader;
|
||||
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.FieldDefinitionSleepStage;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionWeatherCondition;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
|
||||
|
||||
// This class is only used to generate code, and will not be packaged in the final apk
|
||||
@RequiresApi(api = Build.VERSION_CODES.O)
|
||||
public class FitCodeGen {
|
||||
public static void main(final String[] args) throws Exception {
|
||||
new FitCodeGen().generate();
|
||||
}
|
||||
|
||||
public void generate() throws IOException {
|
||||
final File factoryFile = new File("app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRecordDataFactory.java");
|
||||
|
||||
final StringBuilder sbFactory = new StringBuilder();
|
||||
String header = getHeader(factoryFile);
|
||||
if (!header.isEmpty()) {
|
||||
sbFactory.append(header);
|
||||
sbFactory.append("\n");
|
||||
}
|
||||
|
||||
sbFactory.append("package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages;\n");
|
||||
sbFactory.append("\n");
|
||||
sbFactory.append("import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordData;\n");
|
||||
sbFactory.append("import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordDefinition;\n");
|
||||
sbFactory.append("import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordHeader;\n");
|
||||
sbFactory.append("\n");
|
||||
sbFactory.append("//\n");
|
||||
sbFactory.append("// WARNING: This class was auto-generated, please avoid modifying it directly.\n");
|
||||
sbFactory.append("// See ").append(getClass().getCanonicalName()).append("\n");
|
||||
sbFactory.append("//\n");
|
||||
sbFactory.append("public class FitRecordDataFactory {\n");
|
||||
sbFactory.append(" private FitRecordDataFactory() {\n");
|
||||
sbFactory.append(" // use create\n");
|
||||
sbFactory.append(" }\n");
|
||||
sbFactory.append("\n");
|
||||
sbFactory.append(" public static RecordData create(final RecordDefinition recordDefinition, final RecordHeader recordHeader) {\n");
|
||||
sbFactory.append(" switch (recordDefinition.getGlobalFITMessage().getNumber()) {\n");
|
||||
|
||||
final ArrayList<GlobalFITMessage> globalFITMessages = new ArrayList<>(GlobalFITMessage.KNOWN_MESSAGES.values());
|
||||
Collections.sort(globalFITMessages, Comparator.comparingInt(GlobalFITMessage::getNumber));
|
||||
|
||||
for (final GlobalFITMessage value : globalFITMessages) {
|
||||
final String className = "Fit" + capitalize(toCamelCase(value.name()));
|
||||
sbFactory.append(" case ").append(value.getNumber()).append(":\n");
|
||||
sbFactory.append(" return new ").append(className).append("(recordDefinition, recordHeader);\n");
|
||||
|
||||
process(value);
|
||||
}
|
||||
|
||||
sbFactory.append(" }\n");
|
||||
sbFactory.append("\n");
|
||||
sbFactory.append(" return new RecordData(recordDefinition, recordHeader);\n");
|
||||
sbFactory.append(" }\n");
|
||||
sbFactory.append("}\n");
|
||||
|
||||
FileUtils.copyStringToFile(sbFactory.toString(), factoryFile, "replace");
|
||||
}
|
||||
|
||||
public void process(final GlobalFITMessage globalFITMessage) throws IOException {
|
||||
final String className = "Fit" + capitalize(toCamelCase(globalFITMessage.name()));
|
||||
final File outputFile = new File("app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/" + className + ".java");
|
||||
|
||||
final List<String> imports = new ArrayList<>();
|
||||
imports.add(Nullable.class.getCanonicalName());
|
||||
imports.add(RecordData.class.getCanonicalName());
|
||||
imports.add(RecordDefinition.class.getCanonicalName());
|
||||
imports.add(RecordHeader.class.getCanonicalName());
|
||||
//imports.add(GBToStringBuilder.class.getCanonicalName());
|
||||
|
||||
Collections.sort(imports);
|
||||
|
||||
for (final GlobalFITMessage.FieldDefinitionPrimitive primitive : globalFITMessage.getFieldDefinitionPrimitives()) {
|
||||
final Class<?> fieldType = getFieldType(primitive);
|
||||
if (!Objects.requireNonNull(fieldType.getCanonicalName()).startsWith("java.lang")) {
|
||||
imports.add(fieldType.getCanonicalName());
|
||||
}
|
||||
}
|
||||
|
||||
final StringBuilder sb = new StringBuilder();
|
||||
String header = getHeader(outputFile);
|
||||
if (!header.isEmpty()) {
|
||||
sb.append(header);
|
||||
sb.append("\n");
|
||||
}
|
||||
|
||||
sb.append("package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages;");
|
||||
sb.append("\n");
|
||||
|
||||
sb.append("\n");
|
||||
boolean anyImport = false;
|
||||
for (final String i : imports) {
|
||||
if (i.startsWith("androidx")) {
|
||||
sb.append("import ").append(i).append(";\n");
|
||||
anyImport = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (anyImport) {
|
||||
sb.append("\n");
|
||||
anyImport = false;
|
||||
}
|
||||
for (final String i : imports) {
|
||||
if (i.startsWith("nodomain.freeyourgadget")) {
|
||||
sb.append("import ").append(i).append(";\n");
|
||||
anyImport = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (anyImport) {
|
||||
sb.append("\n");
|
||||
anyImport = false;
|
||||
}
|
||||
for (final String i : imports) {
|
||||
if (!i.startsWith("androidx") && !i.startsWith("nodomain.freeyourgadget")) {
|
||||
sb.append("import ").append(i).append(";\n");
|
||||
anyImport = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (anyImport) {
|
||||
sb.append("\n");
|
||||
}
|
||||
sb.append("//\n");
|
||||
sb.append("// WARNING: This class was auto-generated, please avoid modifying it directly.\n");
|
||||
sb.append("// See ").append(getClass().getCanonicalName()).append("\n");
|
||||
sb.append("//\n");
|
||||
sb.append("public class ").append(className).append(" extends RecordData {\n");
|
||||
sb.append(" public ").append(className).append("(final RecordDefinition recordDefinition, final RecordHeader recordHeader) {\n");
|
||||
sb.append(" super(recordDefinition, recordHeader);\n");
|
||||
sb.append("\n");
|
||||
sb.append(" final int globalNumber = recordDefinition.getGlobalFITMessage().getNumber();\n");
|
||||
sb.append(" if (globalNumber != ").append(globalFITMessage.getNumber()).append(") {\n");
|
||||
sb.append(" throw new IllegalArgumentException(\"FitFileId expects global messages of \" + ").append(globalFITMessage.getNumber()).append(" + \", got \" + globalNumber);\n");
|
||||
sb.append(" }\n");
|
||||
sb.append(" }\n");
|
||||
|
||||
for (final GlobalFITMessage.FieldDefinitionPrimitive primitive : globalFITMessage.getFieldDefinitionPrimitives()) {
|
||||
final Class<?> fieldType = getFieldType(primitive);
|
||||
final String fieldTypeName = fieldType.getSimpleName();
|
||||
sb.append("\n");
|
||||
sb.append(" @Nullable\n");
|
||||
sb.append(" public ").append(fieldTypeName).append(method(" get", primitive)).append("() {\n");
|
||||
sb.append(" return (").append(fieldTypeName).append(") getFieldByNumber(").append(primitive.getNumber()).append(");\n");
|
||||
sb.append(" }\n");
|
||||
}
|
||||
|
||||
//sb.append("\n");
|
||||
//sb.append(" @NonNull\n");
|
||||
//sb.append(" @Override\n");
|
||||
//sb.append(" public String toString() {\n");
|
||||
//sb.append(" return new GBToStringBuilder(this)\n");
|
||||
//for (final GlobalFITMessage.FieldDefinitionPrimitive primitive : globalFITMessage.getFieldDefinitionPrimitives()) {
|
||||
// sb.append(" .append(\"").append(primitive.getName()).append("\",").append(method(" get", primitive)).append("())\n");
|
||||
//}
|
||||
//sb.append(" .build();\n");
|
||||
//sb.append(" }\n");
|
||||
|
||||
if (outputFile.exists()) {
|
||||
// Keep manual changes if any
|
||||
final String fileContents = new String(Files.readAllBytes(outputFile.toPath()), StandardCharsets.UTF_8);
|
||||
final int manualChangesIndex = fileContents.indexOf("// manual changes below");
|
||||
if (manualChangesIndex > 0) {
|
||||
sb.append("\n");
|
||||
sb.append(" ");
|
||||
sb.append(fileContents.substring(manualChangesIndex));
|
||||
} else {
|
||||
sb.append("}\n");
|
||||
}
|
||||
} else {
|
||||
sb.append("}\n");
|
||||
}
|
||||
|
||||
FileUtils.copyStringToFile(sb.toString(), outputFile, "replace");
|
||||
}
|
||||
|
||||
public Class<?> getFieldType(final GlobalFITMessage.FieldDefinitionPrimitive primitive) {
|
||||
if (primitive.getType() != null) {
|
||||
switch (primitive.getType()) {
|
||||
case ALARM:
|
||||
return Calendar.class;
|
||||
case DAY_OF_WEEK:
|
||||
return DayOfWeek.class;
|
||||
case FILE_TYPE:
|
||||
return FieldDefinitionFileType.Type.class;
|
||||
case GOAL_SOURCE:
|
||||
return FieldDefinitionGoalSource.Source.class;
|
||||
case GOAL_TYPE:
|
||||
return FieldDefinitionGoalType.Type.class;
|
||||
case MEASUREMENT_SYSTEM:
|
||||
return FieldDefinitionMeasurementSystem.Type.class;
|
||||
case TEMPERATURE:
|
||||
return Integer.class;
|
||||
case TIMESTAMP:
|
||||
return Long.class;
|
||||
case WEATHER_CONDITION:
|
||||
return FieldDefinitionWeatherCondition.Condition.class;
|
||||
case LANGUAGE:
|
||||
return FieldDefinitionLanguage.Language.class;
|
||||
case SLEEP_STAGE:
|
||||
return FieldDefinitionSleepStage.SleepStage.class;
|
||||
}
|
||||
|
||||
throw new RuntimeException("Unknown field type " + primitive.getType());
|
||||
}
|
||||
|
||||
switch (primitive.getBaseType()) {
|
||||
case ENUM:
|
||||
case SINT8:
|
||||
case UINT8:
|
||||
case SINT16:
|
||||
case UINT16:
|
||||
case UINT8Z:
|
||||
case UINT16Z:
|
||||
case BASE_TYPE_BYTE:
|
||||
return Integer.class;
|
||||
case SINT32:
|
||||
case UINT32:
|
||||
case UINT32Z:
|
||||
case SINT64:
|
||||
case UINT64:
|
||||
case UINT64Z:
|
||||
return Long.class;
|
||||
case STRING:
|
||||
return String.class;
|
||||
case FLOAT32:
|
||||
return Float.class;
|
||||
case FLOAT64:
|
||||
return Double.class;
|
||||
}
|
||||
|
||||
throw new RuntimeException("Unknown base type " + primitive.getBaseType());
|
||||
}
|
||||
|
||||
public String toCamelCase(final String str) {
|
||||
final StringBuilder sb = new StringBuilder(str.toLowerCase());
|
||||
|
||||
for (int i = 0; i < sb.length(); i++) {
|
||||
if (sb.charAt(i) == '_') {
|
||||
sb.deleteCharAt(i);
|
||||
sb.replace(i, i + 1, String.valueOf(Character.toUpperCase(sb.charAt(i))));
|
||||
}
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public String method(final String methodName, final GlobalFITMessage.FieldDefinitionPrimitive primitive) {
|
||||
return methodName + capitalize(toCamelCase(primitive.getName()));
|
||||
}
|
||||
|
||||
public String capitalize(final String str) {
|
||||
return str.substring(0, 1).toUpperCase() + str.substring(1);
|
||||
}
|
||||
|
||||
public String getHeader(final File file) throws IOException {
|
||||
if (file.exists()) {
|
||||
final String fileContents = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
||||
final int packageIndex = fileContents.indexOf("package") - 1;
|
||||
if (packageIndex > 0) {
|
||||
return fileContents.substring(0, packageIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
public 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 FieldDefinitionSleepStage extends FieldDefinition {
|
||||
public FieldDefinitionSleepStage(final int localNumber, final int size, final BaseType baseType, final String name) {
|
||||
super(localNumber, size, baseType, name, 1, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object decode(final ByteBuffer byteBuffer) {
|
||||
final int raw = (int) baseType.decode(byteBuffer, scale, offset);
|
||||
return SleepStage.fromId(raw);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void encode(final ByteBuffer byteBuffer, final Object o) {
|
||||
if (o instanceof SleepStage) {
|
||||
baseType.encode(byteBuffer, (((SleepStage) o).getId()), scale, offset);
|
||||
return;
|
||||
}
|
||||
baseType.encode(byteBuffer, o, scale, offset);
|
||||
}
|
||||
|
||||
public enum SleepStage {
|
||||
AWAKE(1),
|
||||
LIGHT(2),
|
||||
DEEP(3),
|
||||
REM(4),
|
||||
;
|
||||
|
||||
private final int id;
|
||||
|
||||
SleepStage(final int i) {
|
||||
id = i;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static SleepStage fromId(final int id) {
|
||||
for (SleepStage stage : SleepStage.values()) {
|
||||
if (id == stage.getId()) {
|
||||
return stage;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public int getId() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
// }
|
||||
}
|
|
@ -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:
|
||||
return 255; //invalid
|
||||
}
|
||||
}
|
||||
|
||||
public 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,
|
||||
;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
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.fit.RecordHeader;
|
||||
|
||||
import java.util.Calendar;
|
||||
|
||||
//
|
||||
// WARNING: This class was auto-generated, please avoid modifying it directly.
|
||||
// See nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.codegen.FitCodeGen
|
||||
//
|
||||
public class FitAlarmSettings extends RecordData {
|
||||
public FitAlarmSettings(final RecordDefinition recordDefinition, final RecordHeader recordHeader) {
|
||||
super(recordDefinition, recordHeader);
|
||||
|
||||
final int globalNumber = recordDefinition.getGlobalFITMessage().getNumber();
|
||||
if (globalNumber != 222) {
|
||||
throw new IllegalArgumentException("FitFileId expects global messages of " + 222 + ", got " + globalNumber);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Calendar getTime() {
|
||||
return (Calendar) getFieldByNumber(0);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
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.fit.RecordHeader;
|
||||
|
||||
//
|
||||
// WARNING: This class was auto-generated, please avoid modifying it directly.
|
||||
// See nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.codegen.FitCodeGen
|
||||
//
|
||||
public class FitConnectivity extends RecordData {
|
||||
public FitConnectivity(final RecordDefinition recordDefinition, final RecordHeader recordHeader) {
|
||||
super(recordDefinition, recordHeader);
|
||||
|
||||
final int globalNumber = recordDefinition.getGlobalFITMessage().getNumber();
|
||||
if (globalNumber != 127) {
|
||||
throw new IllegalArgumentException("FitFileId expects global messages of " + 127 + ", got " + globalNumber);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getBluetoothEnabled() {
|
||||
return (Integer) getFieldByNumber(0);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getName() {
|
||||
return (String) getFieldByNumber(3);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getLiveTrackingEnabled() {
|
||||
return (Integer) getFieldByNumber(4);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getWeatherConditionsEnabled() {
|
||||
return (Integer) getFieldByNumber(5);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getWeatherAlertsEnabled() {
|
||||
return (Integer) getFieldByNumber(6);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getAutoActivityUploadEnabled() {
|
||||
return (Integer) getFieldByNumber(7);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getCourseDownloadEnabled() {
|
||||
return (Integer) getFieldByNumber(8);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getWorkoutDownloadEnabled() {
|
||||
return (Integer) getFieldByNumber(9);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getGpsEphemerisDownloadEnabled() {
|
||||
return (Integer) getFieldByNumber(10);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
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.fit.RecordHeader;
|
||||
|
||||
//
|
||||
// WARNING: This class was auto-generated, please avoid modifying it directly.
|
||||
// See nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.codegen.FitCodeGen
|
||||
//
|
||||
public class FitDeveloperData extends RecordData {
|
||||
public FitDeveloperData(final RecordDefinition recordDefinition, final RecordHeader recordHeader) {
|
||||
super(recordDefinition, recordHeader);
|
||||
|
||||
final int globalNumber = recordDefinition.getGlobalFITMessage().getNumber();
|
||||
if (globalNumber != 207) {
|
||||
throw new IllegalArgumentException("FitFileId expects global messages of " + 207 + ", got " + globalNumber);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getApplicationId() {
|
||||
return (Integer) getFieldByNumber(1);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getDeveloperDataIndex() {
|
||||
return (Integer) getFieldByNumber(3);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
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.fit.RecordHeader;
|
||||
|
||||
//
|
||||
// WARNING: This class was auto-generated, please avoid modifying it directly.
|
||||
// See nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.codegen.FitCodeGen
|
||||
//
|
||||
public class FitDeviceInfo extends RecordData {
|
||||
public FitDeviceInfo(final RecordDefinition recordDefinition, final RecordHeader recordHeader) {
|
||||
super(recordDefinition, recordHeader);
|
||||
|
||||
final int globalNumber = recordDefinition.getGlobalFITMessage().getNumber();
|
||||
if (globalNumber != 23) {
|
||||
throw new IllegalArgumentException("FitFileId expects global messages of " + 23 + ", got " + globalNumber);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getManufacturer() {
|
||||
return (Integer) getFieldByNumber(2);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Long getSerialNumber() {
|
||||
return (Long) getFieldByNumber(3);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getProduct() {
|
||||
return (Integer) getFieldByNumber(4);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getSoftwareVersion() {
|
||||
return (Integer) getFieldByNumber(5);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Long getTimestamp() {
|
||||
return (Long) getFieldByNumber(253);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,102 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
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.fit.RecordHeader;
|
||||
|
||||
//
|
||||
// WARNING: This class was auto-generated, please avoid modifying it directly.
|
||||
// See nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.codegen.FitCodeGen
|
||||
//
|
||||
public class FitDeviceSettings extends RecordData {
|
||||
public FitDeviceSettings(final RecordDefinition recordDefinition, final RecordHeader recordHeader) {
|
||||
super(recordDefinition, recordHeader);
|
||||
|
||||
final int globalNumber = recordDefinition.getGlobalFITMessage().getNumber();
|
||||
if (globalNumber != 2) {
|
||||
throw new IllegalArgumentException("FitFileId expects global messages of " + 2 + ", got " + globalNumber);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getActiveTimeZone() {
|
||||
return (Integer) getFieldByNumber(0);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Long getUtcOffset() {
|
||||
return (Long) getFieldByNumber(1);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Long getTimeOffset() {
|
||||
return (Long) getFieldByNumber(2);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getTimeMode() {
|
||||
return (Integer) getFieldByNumber(4);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getTimeZoneOffset() {
|
||||
return (Integer) getFieldByNumber(5);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getBacklightMode() {
|
||||
return (Integer) getFieldByNumber(12);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getActivityTrackerEnabled() {
|
||||
return (Integer) getFieldByNumber(36);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getMoveAlertEnabled() {
|
||||
return (Integer) getFieldByNumber(46);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getDateMode() {
|
||||
return (Integer) getFieldByNumber(47);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getDisplayOrientation() {
|
||||
return (Integer) getFieldByNumber(55);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getMountingSide() {
|
||||
return (Integer) getFieldByNumber(56);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getDefaultPage() {
|
||||
return (Integer) getFieldByNumber(57);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getAutosyncMinSteps() {
|
||||
return (Integer) getFieldByNumber(58);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getAutosyncMinTime() {
|
||||
return (Integer) getFieldByNumber(59);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getBleAutoUploadEnabled() {
|
||||
return (Integer) getFieldByNumber(86);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Long getAutoActivityDetect() {
|
||||
return (Long) getFieldByNumber(90);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
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.fit.RecordHeader;
|
||||
|
||||
//
|
||||
// WARNING: This class was auto-generated, please avoid modifying it directly.
|
||||
// See nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.codegen.FitCodeGen
|
||||
//
|
||||
public class FitFieldDescription extends RecordData {
|
||||
public FitFieldDescription(final RecordDefinition recordDefinition, final RecordHeader recordHeader) {
|
||||
super(recordDefinition, recordHeader);
|
||||
|
||||
final int globalNumber = recordDefinition.getGlobalFITMessage().getNumber();
|
||||
if (globalNumber != 206) {
|
||||
throw new IllegalArgumentException("FitFileId expects global messages of " + 206 + ", got " + globalNumber);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getDeveloperDataIndex() {
|
||||
return (Integer) getFieldByNumber(0);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getFieldDefinitionNumber() {
|
||||
return (Integer) getFieldByNumber(1);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getFitBaseTypeId() {
|
||||
return (Integer) getFieldByNumber(2);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getFieldName() {
|
||||
return (String) getFieldByNumber(3);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getUnits() {
|
||||
return (String) getFieldByNumber(8);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
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.fit.RecordHeader;
|
||||
|
||||
//
|
||||
// WARNING: This class was auto-generated, please avoid modifying it directly.
|
||||
// See nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.codegen.FitCodeGen
|
||||
//
|
||||
public class FitFileCreator extends RecordData {
|
||||
public FitFileCreator(final RecordDefinition recordDefinition, final RecordHeader recordHeader) {
|
||||
super(recordDefinition, recordHeader);
|
||||
|
||||
final int globalNumber = recordDefinition.getGlobalFITMessage().getNumber();
|
||||
if (globalNumber != 49) {
|
||||
throw new IllegalArgumentException("FitFileId expects global messages of " + 49 + ", got " + globalNumber);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getSoftwareVersion() {
|
||||
return (Integer) getFieldByNumber(0);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getHardwareVersion() {
|
||||
return (Integer) getFieldByNumber(1);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
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.fit.RecordHeader;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionFileType.Type;
|
||||
|
||||
//
|
||||
// WARNING: This class was auto-generated, please avoid modifying it directly.
|
||||
// See nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.codegen.FitCodeGen
|
||||
//
|
||||
public class FitFileId extends RecordData {
|
||||
public FitFileId(final RecordDefinition recordDefinition, final RecordHeader recordHeader) {
|
||||
super(recordDefinition, recordHeader);
|
||||
|
||||
final int globalNumber = recordDefinition.getGlobalFITMessage().getNumber();
|
||||
if (globalNumber != 0) {
|
||||
throw new IllegalArgumentException("FitFileId expects global messages of " + 0 + ", got " + globalNumber);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Type getType() {
|
||||
return (Type) getFieldByNumber(0);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getManufacturer() {
|
||||
return (Integer) getFieldByNumber(1);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getProduct() {
|
||||
return (Integer) getFieldByNumber(2);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Long getSerialNumber() {
|
||||
return (Long) getFieldByNumber(3);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Long getTimeCreated() {
|
||||
return (Long) getFieldByNumber(4);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getNumber() {
|
||||
return (Integer) getFieldByNumber(5);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getManufacturerPartner() {
|
||||
return (Integer) getFieldByNumber(6);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String getProductName() {
|
||||
return (String) getFieldByNumber(8);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
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.fit.RecordHeader;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionGoalType.Type;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionGoalSource.Source;
|
||||
|
||||
//
|
||||
// WARNING: This class was auto-generated, please avoid modifying it directly.
|
||||
// See nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.codegen.FitCodeGen
|
||||
//
|
||||
public class FitGoals extends RecordData {
|
||||
public FitGoals(final RecordDefinition recordDefinition, final RecordHeader recordHeader) {
|
||||
super(recordDefinition, recordHeader);
|
||||
|
||||
final int globalNumber = recordDefinition.getGlobalFITMessage().getNumber();
|
||||
if (globalNumber != 15) {
|
||||
throw new IllegalArgumentException("FitFileId expects global messages of " + 15 + ", got " + globalNumber);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Type getType() {
|
||||
return (Type) getFieldByNumber(4);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Long getTargetValue() {
|
||||
return (Long) getFieldByNumber(7);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Source getSource() {
|
||||
return (Source) getFieldByNumber(11);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
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.fit.RecordHeader;
|
||||
|
||||
//
|
||||
// WARNING: This class was auto-generated, please avoid modifying it directly.
|
||||
// See nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.codegen.FitCodeGen
|
||||
//
|
||||
public class FitMonitoring extends RecordData {
|
||||
public FitMonitoring(final RecordDefinition recordDefinition, final RecordHeader recordHeader) {
|
||||
super(recordDefinition, recordHeader);
|
||||
|
||||
final int globalNumber = recordDefinition.getGlobalFITMessage().getNumber();
|
||||
if (globalNumber != 55) {
|
||||
throw new IllegalArgumentException("FitFileId expects global messages of " + 55 + ", got " + globalNumber);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Long getDistance() {
|
||||
return (Long) getFieldByNumber(2);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Long getCycles() {
|
||||
return (Long) getFieldByNumber(3);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Long getActiveTime() {
|
||||
return (Long) getFieldByNumber(4);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getActivityType() {
|
||||
return (Integer) getFieldByNumber(5);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getActiveCalories() {
|
||||
return (Integer) getFieldByNumber(19);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getDurationMin() {
|
||||
return (Integer) getFieldByNumber(29);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getCurrentActivityTypeIntensity() {
|
||||
return (Integer) getFieldByNumber(24);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getTimestamp16() {
|
||||
return (Integer) getFieldByNumber(26);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getHeartRate() {
|
||||
return (Integer) getFieldByNumber(27);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Long getTimestamp() {
|
||||
return (Long) getFieldByNumber(253);
|
||||
}
|
||||
|
||||
// manual changes below
|
||||
|
||||
@Override
|
||||
public Long getComputedTimestamp() {
|
||||
final Integer timestamp16 = getTimestamp16();
|
||||
final Long computedTimestamp = super.getComputedTimestamp();
|
||||
if (timestamp16 != null && computedTimestamp != null) {
|
||||
return (computedTimestamp & ~0xFFFFL) | timestamp16;
|
||||
}
|
||||
return computedTimestamp;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
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.fit.RecordHeader;
|
||||
|
||||
//
|
||||
// WARNING: This class was auto-generated, please avoid modifying it directly.
|
||||
// See nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.codegen.FitCodeGen
|
||||
//
|
||||
public class FitRecord extends RecordData {
|
||||
public FitRecord(final RecordDefinition recordDefinition, final RecordHeader recordHeader) {
|
||||
super(recordDefinition, recordHeader);
|
||||
|
||||
final int globalNumber = recordDefinition.getGlobalFITMessage().getNumber();
|
||||
if (globalNumber != 20) {
|
||||
throw new IllegalArgumentException("FitFileId expects global messages of " + 20 + ", got " + globalNumber);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getHeartRate() {
|
||||
return (Integer) getFieldByNumber(3);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Long getTimestamp() {
|
||||
return (Long) getFieldByNumber(253);
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue