From 4ec30d1e525726a462a5c7596448fb87e69c5a96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Rebelo?= Date: Tue, 6 Jan 2026 20:57:32 +0000 Subject: [PATCH] OneTouch: Initial support --- .idea/dictionaries/t.xml | 6 + .../gadgetbridge/daogen/GBDaoGenerator.java | 10 +- .../DeviceSettingsPreferenceConst.java | 2 + .../devicesettings/DeviceSettingsUtils.java | 23 +- .../DeviceSpecificSettingsFragment.java | 2 + .../devices/AbstractDeviceCoordinator.java | 5 + .../devices/DeviceCoordinator.java | 2 + .../gadgetbridge/devices/EventHandler.java | 2 +- .../devices/GlucoseSampleProvider.kt | 45 ++ .../gloryfit/GloryFitSettingsCustomizer.kt | 12 +- .../devices/onetouch/OneTouchCoordinator.kt | 113 ++++ .../onetouch/OneTouchSettingsCustomizer.kt | 70 +++ .../gadgetbridge/model/DeviceType.java | 2 + .../gadgetbridge/service/DeviceSupport.java | 5 +- .../service/btle/BleNamesResolver.java | 3 + .../devices/onetouch/OneTouchMessage.kt | 239 ++++++++ .../devices/onetouch/OneTouchSupport.kt | 453 +++++++++++++++ .../gadgetbridge/util/CheckSums.java | 11 +- .../kotlin/TransactionBuilderExtensions.kt | 12 + app/src/main/res/drawable/ic_glucose.xml | 10 + app/src/main/res/values/strings.xml | 5 + .../res/xml/devicesettings_glucose_limits.xml | 19 + .../devices/onetouch/OneTouchMessageTest.kt | 515 ++++++++++++++++++ 23 files changed, 1546 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/GlucoseSampleProvider.kt create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/onetouch/OneTouchCoordinator.kt create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/onetouch/OneTouchSettingsCustomizer.kt create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/onetouch/OneTouchMessage.kt create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/onetouch/OneTouchSupport.kt create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/kotlin/TransactionBuilderExtensions.kt create mode 100644 app/src/main/res/drawable/ic_glucose.xml create mode 100644 app/src/main/res/xml/devicesettings_glucose_limits.xml create mode 100644 app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/onetouch/OneTouchMessageTest.kt diff --git a/.idea/dictionaries/t.xml b/.idea/dictionaries/t.xml index b585333578..0d84818c5f 100644 --- a/.idea/dictionaries/t.xml +++ b/.idea/dictionaries/t.xml @@ -69,6 +69,7 @@ hasants hauck hiit + hmac hplus huami ieee @@ -113,6 +114,8 @@ maxirnilian miband mijia + miwear + moondrop morpheuz mosenkovs multisport @@ -124,6 +127,7 @@ nordhøy normano novotny + onetouch opentracks oraclejdk oukitel @@ -138,6 +142,7 @@ protomors qhybrid quallenauge + rcvd realme rebelo roidmi @@ -155,6 +160,7 @@ skype slezak sophanimus + soundcore spalding spotify stacktraces diff --git a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java index c158354683..9d07cd1672 100644 --- a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java +++ b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java @@ -67,7 +67,7 @@ public class GBDaoGenerator { private static final String TIMESTAMP_TO = "timestampTo"; public static void main(String[] args) throws Exception { - final Schema schema = new Schema(123, MAIN_PACKAGE + ".entities"); + final Schema schema = new Schema(124, MAIN_PACKAGE + ".entities"); Entity userAttributes = addUserAttributes(schema); Entity user = addUserInfo(schema, userAttributes); @@ -231,6 +231,7 @@ public class GBDaoGenerator { addGenericTrainingLoadAcuteSample(schema, user, device); addGenericTrainingLoadChronicSample(schema, user, device); addGenericWeightSample(schema, user, device); + addGlucoseSample(schema, user, device); deleteOldFiles(); @@ -2201,4 +2202,11 @@ public class GBDaoGenerator { sample.addFloatProperty(SAMPLE_WEIGHT_KG).notNull().codeBeforeGetter(OVERRIDE); return sample; } + + private static Entity addGlucoseSample(Schema schema, Entity user, Entity device) { + Entity sample = addEntity(schema, "GlucoseSample"); + addCommonTimeSampleProperties("AbstractTimeSample", sample, user, device); + sample.addDoubleProperty("valueMgDl").notNull(); + return sample; + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java index 017548c0cf..b119e323f2 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java @@ -230,6 +230,8 @@ public class DeviceSettingsPreferenceConst { public static final String PREF_SPO2_LOW_ALERT_THRESHOLD = "spo2_low_alert_threshold"; public static final String PREF_HRV_ALL_DAY_MONITORING = "hrv_all_day_monitoring_enabled"; public static final String PREF_TEMPERATURE_ALL_DAY_MONITORING = "continuous_skin_temperature_measurement"; + public static final String PREF_GLUCOSE_THRESHOLD_HIGH = "glucose_threshold_high"; + public static final String PREF_GLUCOSE_THRESHOLD_LOW = "glucose_threshold_low"; public static final String PREF_AUTOHEARTRATE_SWITCH = "pref_autoheartrate_switch"; public static final String PREF_AUTOHEARTRATE_SLEEP = "pref_autoheartrate_sleep"; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsUtils.java index 90d7d94068..6ead69a78b 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsUtils.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsUtils.java @@ -21,6 +21,7 @@ import android.text.InputFilter; import android.text.InputType; import android.text.Spanned; +import androidx.annotation.StringRes; import androidx.preference.EditTextPreference; import androidx.preference.ListPreference; import androidx.preference.MultiSelectListPreference; @@ -283,10 +284,12 @@ public final class DeviceSettingsUtils { }); } - public static void populateWithBpmRange(final CharSequence prefKey, - final DeviceSpecificSettingsHandler handler, - final int rangeMin, - final int rangeMax) { + public static void populateWithRange(final CharSequence prefKey, + final DeviceSpecificSettingsHandler handler, + final int rangeMin, + final int rangeMax, + @StringRes final int stringRes, + final boolean includeOff) { final Preference pref = handler.findPreference(prefKey); if (pref == null) { return; @@ -296,14 +299,16 @@ public final class DeviceSettingsUtils { throw new IllegalArgumentException("Invalid range [" + rangeMin + ", " + rangeMax + "]"); } - final CharSequence[] entries = new CharSequence[rangeMax - rangeMin + 2]; - final CharSequence[] values = new CharSequence[rangeMax - rangeMin + 2]; + final int numEntries = rangeMax - rangeMin + (includeOff ? 2 : 1); + final CharSequence[] entries = new CharSequence[numEntries]; + final CharSequence[] values = new CharSequence[numEntries]; entries[0] = handler.getContext().getString(R.string.off); values[0] = "0"; - for (int i = 1, bpm = rangeMin; bpm <= rangeMax; i++, bpm++) { - entries[i] = handler.getContext().getString(R.string.bpm_value_unit, bpm); - values[i] = String.valueOf(bpm); + final int start = includeOff ? 1 : 0; + for (int i = start, value = rangeMin; value <= rangeMax - start; i++, value++) { + entries[i] = handler.getContext().getString(stringRes, value); + values[i] = String.valueOf(value); } if (pref instanceof ListPreference) { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java index 3886315934..435104442f 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java @@ -637,6 +637,8 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i addPreferenceHandlerFor(PREF_SPO2_LOW_ALERT_THRESHOLD); addPreferenceHandlerFor(PREF_HRV_ALL_DAY_MONITORING); addPreferenceHandlerFor(PREF_TEMPERATURE_ALL_DAY_MONITORING); + addPreferenceHandlerFor(PREF_GLUCOSE_THRESHOLD_HIGH); + addPreferenceHandlerFor(PREF_GLUCOSE_THRESHOLD_LOW); addPreferenceHandlerFor(PREF_DO_NOT_DISTURB_NOAUTO); addPreferenceHandlerFor(PREF_DO_NOT_DISTURB_NOAUTO_START); addPreferenceHandlerFor(PREF_DO_NOT_DISTURB_NOAUTO_END); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java index 686d507094..5e09cd6b9b 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java @@ -696,6 +696,11 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator { return supportsActivityTracking(device); } + @Override + public boolean supportsGlucoseMeasurement(@NonNull final GBDevice device) { + return false; + } + @Override public boolean supportsTemperatureMeasurement(@NonNull final GBDevice device) { return false; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java index 494e3ea2e2..e0dba778a0 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java @@ -152,6 +152,7 @@ public interface DeviceCoordinator { SMART_CLOCK, THERMOMETER, PV_EQUIPMENT, + GLUCOSE_METER, } /** @@ -285,6 +286,7 @@ public interface DeviceCoordinator { boolean supportsActiveCalories(@NonNull GBDevice device); boolean supportsTrainingLoad(@NonNull GBDevice device); boolean supportsWorkoutLoad(@NonNull GBDevice device); + boolean supportsGlucoseMeasurement(@NonNull GBDevice device); /** * Returns true if measurement and fetching of body temperature is supported by the device diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/EventHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/EventHandler.java index ea7265a1b6..8134405d67 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/EventHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/EventHandler.java @@ -127,7 +127,7 @@ public interface EventHandler { * The config name is device specific. * @param config the device specific option to set on the device */ - void onSendConfiguration(String config); + void onSendConfiguration(@NonNull String config); /** * Gets the given option from the device, sets the values in the preferences. diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/GlucoseSampleProvider.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/GlucoseSampleProvider.kt new file mode 100644 index 0000000000..5d5e06359e --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/GlucoseSampleProvider.kt @@ -0,0 +1,45 @@ +/* Copyright (C) 2026 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.devices + +import de.greenrobot.dao.AbstractDao +import de.greenrobot.dao.Property +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession +import nodomain.freeyourgadget.gadgetbridge.entities.GlucoseSample +import nodomain.freeyourgadget.gadgetbridge.entities.GlucoseSampleDao +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice + +class GlucoseSampleProvider( + device: GBDevice, + session: DaoSession +) : AbstractTimeSampleProvider(device, session) { + override fun getSampleDao(): AbstractDao { + return session.glucoseSampleDao + } + + override fun getTimestampSampleProperty(): Property { + return GlucoseSampleDao.Properties.Timestamp + } + + override fun getDeviceIdentifierSampleProperty(): Property { + return GlucoseSampleDao.Properties.DeviceId + } + + override fun createSample(): GlucoseSample { + return GlucoseSample() + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/gloryfit/GloryFitSettingsCustomizer.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/gloryfit/GloryFitSettingsCustomizer.kt index e5234f2f31..a34bf1144b 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/gloryfit/GloryFitSettingsCustomizer.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/gloryfit/GloryFitSettingsCustomizer.kt @@ -42,18 +42,22 @@ class GloryFitSettingsCustomizer : DeviceSpecificSettingsCustomizer { genericDevicePrefs: Prefs, rootKey: String? ) { - DeviceSettingsUtils.populateWithBpmRange( + DeviceSettingsUtils.populateWithRange( DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_HIGH_THRESHOLD, handler, 100, - 200 + 200, + R.string.bpm_value_unit, + true ) - DeviceSettingsUtils.populateWithBpmRange( + DeviceSettingsUtils.populateWithRange( DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_LOW_THRESHOLD, handler, 40, - 100 + 100, + R.string.bpm_value_unit, + true ) // Inactivity reminders diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/onetouch/OneTouchCoordinator.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/onetouch/OneTouchCoordinator.kt new file mode 100644 index 0000000000..ae795b4e3a --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/onetouch/OneTouchCoordinator.kt @@ -0,0 +1,113 @@ +package nodomain.freeyourgadget.gadgetbridge.devices.onetouch + +import de.greenrobot.dao.AbstractDao +import de.greenrobot.dao.Property +import nodomain.freeyourgadget.gadgetbridge.R +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer +import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator +import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession +import nodomain.freeyourgadget.gadgetbridge.entities.GlucoseSampleDao +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice +import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport +import nodomain.freeyourgadget.gadgetbridge.service.ServiceDeviceSupport +import nodomain.freeyourgadget.gadgetbridge.service.devices.onetouch.OneTouchSupport +import java.util.EnumSet +import java.util.regex.Pattern + +class OneTouchCoordinator : AbstractBLEDeviceCoordinator() { + override fun isExperimental(): Boolean { + return true + } + + override fun getManufacturer(): String { + return "LifeScan" + } + + override fun getDeviceSupportClass(device: GBDevice): Class { + return OneTouchSupport::class.java + } + + override fun getInitialFlags(): EnumSet { + // Support class has message queuing, we don't need busy checking + return EnumSet.noneOf(ServiceDeviceSupport.Flags::class.java) + } + + override fun getBondingStyle(): Int { + return BONDING_STYLE_BOND + } + + override fun suggestUnbindBeforePair(): Boolean { + return false + } + + override fun getDefaultIconResource(): Int { + // TODO dedicated icon + return R.drawable.ic_device_default + } + + override fun getSupportedDeviceName(): Pattern? { + // Contains last 4 digits of the serial number + return Pattern.compile("^OneTouch [A-Z0-9]{4}$") + } + + override fun getDeviceNameResource(): Int { + return R.string.devicetype_onetouch + } + + override fun getDeviceKind(device: GBDevice): DeviceCoordinator.DeviceKind { + return DeviceCoordinator.DeviceKind.GLUCOSE_METER + } + + override fun getDeviceSpecificSettings(device: GBDevice): DeviceSpecificSettings { + val settings = DeviceSpecificSettings() + + settings.addRootScreen(R.xml.devicesettings_glucose_limits) + // FIXME: Read-only for now + //settings.addConnectedPreferences(DeviceSettingsPreferenceConst.PREF_GLUCOSE_THRESHOLD_LOW) + //settings.addConnectedPreferences(DeviceSettingsPreferenceConst.PREF_GLUCOSE_THRESHOLD_HIGH) + + return settings + } + + override fun getDeviceSpecificSettingsCustomizer(device: GBDevice): DeviceSpecificSettingsCustomizer? { + return OneTouchSettingsCustomizer() + } + + override fun getAllDeviceDao(session: DaoSession): MutableMap, Property> { + return object : HashMap, Property>() { + init { + put(session.glucoseSampleDao, GlucoseSampleDao.Properties.DeviceId) + } + } + } + + override fun supportsActivityDataFetching(device: GBDevice): Boolean { + return true + } + + override fun supportsActivityTracking(device: GBDevice): Boolean { + return false // TODO charts are not yet implemented + } + + override fun supportsActivityTabs(device: GBDevice): Boolean { + return false + } + + override fun supportsSleepMeasurement(device: GBDevice): Boolean { + return false + } + + override fun supportsStepCounter(device: GBDevice): Boolean { + return false + } + + override fun supportsSpeedzones(device: GBDevice): Boolean { + return false + } + + override fun supportsGlucoseMeasurement(device: GBDevice): Boolean { + return true + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/onetouch/OneTouchSettingsCustomizer.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/onetouch/OneTouchSettingsCustomizer.kt new file mode 100644 index 0000000000..85545e1fd0 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/onetouch/OneTouchSettingsCustomizer.kt @@ -0,0 +1,70 @@ +/* Copyright (C) 2026 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.devices.onetouch + +import androidx.preference.Preference +import kotlinx.parcelize.Parcelize +import nodomain.freeyourgadget.gadgetbridge.R +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsUtils +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler +import nodomain.freeyourgadget.gadgetbridge.util.Prefs + +@Parcelize +class OneTouchSettingsCustomizer : DeviceSpecificSettingsCustomizer { + override fun onPreferenceChange( + preference: Preference, + handler: DeviceSpecificSettingsHandler + ) { + } + + override fun onDeviceChanged(handler: DeviceSpecificSettingsHandler) { + } + + override fun customizeSettings( + handler: DeviceSpecificSettingsHandler, + genericDevicePrefs: Prefs, + rootKey: String? + ) { + // FIXME: Read-only for now + handler.findPreference(DeviceSettingsPreferenceConst.PREF_GLUCOSE_THRESHOLD_LOW)?.isSelectable = false + handler.findPreference(DeviceSettingsPreferenceConst.PREF_GLUCOSE_THRESHOLD_HIGH)?.isSelectable = false + + DeviceSettingsUtils.populateWithRange( + DeviceSettingsPreferenceConst.PREF_GLUCOSE_THRESHOLD_LOW, + handler, + 60, + 110, + R.string.glucose_value_mg_dl, + false + ) + + DeviceSettingsUtils.populateWithRange( + DeviceSettingsPreferenceConst.PREF_GLUCOSE_THRESHOLD_HIGH, + handler, + 90, + 300, + R.string.glucose_value_mg_dl, + false + ) + } + + override fun getPreferenceKeysWithSummary(): Set { + return setOf() + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java index a84fcb4350..35c0433229 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java @@ -340,6 +340,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.nothing.EarACoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.nothing.EarStickCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.nut.NutCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.onemoresonoflow.OneMoreSonoFlowCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.onetouch.OneTouchCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.oppo.OppoEncoAir2Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.oppo.OppoEncoAirCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.oppo.OppoEncoBuds2Coordinator; @@ -751,6 +752,7 @@ public enum DeviceType { SONY_WH_CH720N(SonyWHCH720NCoordinator.class), PIXEL_BUDS_A(PixelBudsACoordinator.class), SHOKZ_OPENSWIM_PRO(ShokzOpenSwimProCoordinator.class), + ONETOUCH(OneTouchCoordinator.class), SOUNDCORE_LIBERTY3_PRO(SoundcoreLiberty3ProCoordinator.class), SOUNDCORE_LIBERTY4_NC(SoundcoreLiberty4NCCoordinator.class), SOUNDCORE_MOTION300(SoundcoreMotion300Coordinator.class), diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceSupport.java index 4025765124..f7dbc7de93 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceSupport.java @@ -20,6 +20,7 @@ import android.bluetooth.BluetoothAdapter; import android.content.Context; import androidx.annotation.CallSuper; +import androidx.annotation.NonNull; import nodomain.freeyourgadget.gadgetbridge.devices.EventHandler; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; @@ -45,7 +46,9 @@ public interface DeviceSupport extends EventHandler { * @param btAdapter the bluetooth adapter to use * @param context the android context, e.g. to look up resources */ - void setContext(GBDevice gbDevice, BluetoothAdapter btAdapter, Context context); + void setContext(@NonNull GBDevice gbDevice, + @NonNull BluetoothAdapter btAdapter, + @NonNull Context context); /** * Returns whether a transport-level connection is established with the device diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BleNamesResolver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BleNamesResolver.java index a179cada00..bcfee9c2aa 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BleNamesResolver.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BleNamesResolver.java @@ -592,6 +592,7 @@ public class BleNamesResolver { mServices.put("77d4e67c-2fe2-2334-0d35-9ccd078f529c", "(Propr: Nothing CMF Shell"); mServices.put("000055ff-0000-1000-8000-00805f9b34fb", "(Propr: GloryFit Command"); mServices.put("000056ff-0000-1000-8000-00805f9b34fb", "(Propr: GloryFit Data"); + mServices.put("af9df7a1-e595-11e3-96b4-0002a5d5c51b", "(Propr: OneTouch"); mServices.put("9b012401-bc30-ce9a-e111-0f67e491abde", "(Propr: Garmin GFDI V0)"); mServices.put("6a4e2401-667b-11e3-949a-0800200c9a66", "(Propr: Garmin GFDI V1)"); mServices.put("6a4e2800-667b-11e3-949a-0800200c9a66", "(Propr: Garmin ML)"); @@ -1134,6 +1135,8 @@ public class BleNamesResolver { mCharacteristics.put("000033f2-0000-1000-8000-00805f9b34fb", "(Propr: GloryFit Command Read"); mCharacteristics.put("000034f1-0000-1000-8000-00805f9b34fb", "(Propr: GloryFit Data Write"); mCharacteristics.put("000034f2-0000-1000-8000-00805f9b34fb", "(Propr: GloryFit Data Read"); + mCharacteristics.put("af9df7a2-e595-11e3-96b4-0002a5d5c51b", "(Propr: OneTouch Write"); + mCharacteristics.put("af9df7a3-e595-11e3-96b4-0002a5d5c51b", "(Propr: OneTouch Read"); mCharacteristics.put("00010203-0405-0607-0809-0a0b0c0d2b12", "(Propr: Telink OTA Write)"); mCharacteristics.put("ebe0ccb7-7a0a-4b0c-8a1a-6ff2997da3a6", "(Propr: Lywsd TIME)"); mCharacteristics.put("ebe0ccc4-7a0a-4b0c-8a1a-6ff2997da3a6", "(Propr: Lywsd BATTERY)"); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/onetouch/OneTouchMessage.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/onetouch/OneTouchMessage.kt new file mode 100644 index 0000000000..482e995ec2 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/onetouch/OneTouchMessage.kt @@ -0,0 +1,239 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.onetouch + +import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions +import nodomain.freeyourgadget.gadgetbridge.util.CheckSums +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.nio.ByteBuffer +import java.nio.ByteOrder + +sealed class OneTouchMessage { + object ThresholdHighGet : OneTouchMessage() { + override fun encodePayload(): ByteArray { + return byteArrayOf(0x0a, 0x02, 0x0a) + } + } + + data class ThresholdHighRet(val threshold: Int) : OneTouchMessage() + + data class ThresholdHighSet(val threshold: Int) : OneTouchMessage() { + override fun encodePayload(): ByteArray { + val buf = ByteBuffer.allocate(7).order(ByteOrder.LITTLE_ENDIAN) + buf.put(byteArrayOf(0x0a, 0x01, 0x0a)) + buf.putInt(threshold) + return buf.array() + } + } + + object ThresholdLowGet : OneTouchMessage() { + override fun encodePayload(): ByteArray { + return byteArrayOf(0x0a, 0x02, 0x09) + } + } + + data class ThresholdLowRet(val threshold: Int) : OneTouchMessage() + + data class ThresholdLowSet(val threshold: Int) : OneTouchMessage() { + override fun encodePayload(): ByteArray { + val buf = ByteBuffer.allocate(7).order(ByteOrder.LITTLE_ENDIAN) + buf.put(byteArrayOf(0x0a, 0x01, 0x09)) + buf.putInt(threshold) + return buf.array() + } + } + + object ReadingCountGet : OneTouchMessage() { + override fun encodePayload(): ByteArray { + return byteArrayOf(0x27, 0x00) + } + } + + data class ReadingCountRet(val count: Short) : OneTouchMessage() + + data class ReadingGet(val offset: Short) : OneTouchMessage() { + override fun encodePayload(): ByteArray { + val buf = ByteBuffer.allocate(5).order(ByteOrder.LITTLE_ENDIAN) + buf.put(byteArrayOf(0x31, 0x02)) + buf.putShort(offset) + buf.put(0x00) + return buf.array() + } + } + + data class ReadingRet(val offset: Int, val index: Int, val timestampMillis: Long, val value: Int) : + OneTouchMessage() + + object TimeGet : OneTouchMessage() { + override fun encodePayload(): ByteArray { + return byteArrayOf(0x20, 0x02) + } + } + + data class TimeRet(val timestampMillis: Long) : OneTouchMessage() + + data class TimeSet(val timestampMillis: Long) : OneTouchMessage() { + override fun encodePayload(): ByteArray { + val buf = ByteBuffer.allocate(6).order(ByteOrder.LITTLE_ENDIAN) + buf.put(byteArrayOf(0x20, 0x01)) + buf.putInt(millisToOneTouchEpoch(timestampMillis)) + return buf.array() + } + } + + data class ValueRet(private val payload: ByteArray) : OneTouchMessage() { + override fun encodePayload(): ByteArray { + return payload + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ValueRet + + return payload.contentEquals(other.payload) + } + + override fun hashCode(): Int { + return payload.contentHashCode() + } + } + + open fun encodePayload(): ByteArray { + throw IllegalAccessException("encodePayload() not implemented") + } + + fun encode(): ByteArray { + val payload = encodePayload() + val packetSize = payload.size + 8 + if (packetSize > 20) { + // Does not fit the MTU, and we do not support chunking + throw IllegalStateException("Packet too long, and chunks are not supported") + } + + val buf = ByteBuffer.allocate(packetSize).order(ByteOrder.LITTLE_ENDIAN) + buf.put(0x01) + buf.put(0x02) + buf.putShort((buf.limit() - 1).toShort()) + if (this is ThresholdLowSet || this is ThresholdHighSet) { + buf.put(0x03) + } else { + buf.put(0x04) + } + buf.put(payload) + buf.put(0x03) + buf.putShort(CheckSums.crc16_ccitt(buf.array(), 1, buf.limit() - 2, 0xffff).toShort()) + + return buf.array() + } + + companion object { + private val LOG: Logger = LoggerFactory.getLogger(OneTouchMessage::class.java) + + fun oneTouchEpochToMillis(timestamp: Int): Long { + return (timestamp + EPOCH_OFFSET_SECS) * 1000L + } + + fun millisToOneTouchEpoch(dateMillis: Long): Int { + return (dateMillis / 1000).toInt() - EPOCH_OFFSET_SECS + } + + // OneTouch uses 2000-01-01 00:00:00 UTC as epoch + private const val EPOCH_OFFSET_SECS = 946684800 + + fun decode(packet: ByteArray, previousMessage: OneTouchMessage): OneTouchMessage? { + if (packet.size < 8) { + LOG.warn("Packet too short: {} bytes", packet.size) + return null + } + + if (packet[1] != 2.toByte()) { + LOG.error("Expected 2 at position 1, got {}", packet[1]) + return null + } + + val length = BLETypeConversions.toUint16(packet, 2) + if (packet.size < length + 1) { + LOG.warn("Packet shorter than advertised length") + return null + } + + if (packet[4] != 3.toByte() && packet[4] != 4.toByte()) { + LOG.error("Expected 3 or 4 at position 4, got {}", packet[4]) + return null + } + + if (packet[packet.size - 3] != 3.toByte()) { + LOG.error("Expected 3 before checksum, got {}", packet[packet.size - 2]) + return null + } + + val computedCrc = CheckSums.crc16_ccitt(packet, 1, packet.size - 2, 0xffff) + val packetCrc = BLETypeConversions.toUint16(packet, packet.size - 2) + if (computedCrc != packetCrc) { + LOG.error("Invalid checksum, expected {}, computed {}", packetCrc, computedCrc) + return null + } + + val payload = packet.copyOfRange(5, packet.size - 3) + + val command = payload[0].toInt() and 0xFF + if (command != 0x06) { + LOG.warn("Unknown command 0x{}", command.toHexString()) + return null + } + + when (previousMessage) { + is TimeGet -> { + if (payload.size == 5) { + val timestamp = BLETypeConversions.toUint32(payload, 1) + return TimeRet(oneTouchEpochToMillis(timestamp)) + } + } + + is ReadingGet -> { + if (payload.size == 17) { + val offset = BLETypeConversions.toUint16(payload, 1) + val index = BLETypeConversions.toUint16(payload, 4) + val timestamp = BLETypeConversions.toUint32(payload, 6) + val value = BLETypeConversions.toUint16(payload, 10) + // ? 5 unknown bytes after the value + + val date = oneTouchEpochToMillis(timestamp) + + return ReadingRet(offset, index, date, value) + } + } + + is ReadingCountGet -> { + if (payload.size == 3) { + val count = BLETypeConversions.toUint16(payload, 1) + return ReadingCountRet(count.toShort()) + } + } + + is ThresholdLowGet -> { + if (payload.size == 5) { + val limit = BLETypeConversions.toUint32(payload, 1) + return ThresholdLowRet(limit) + } + } + + is ThresholdHighGet -> { + if (payload.size == 5) { + val limit = BLETypeConversions.toUint32(payload, 1) + return ThresholdHighRet(limit) + } + } + + else -> { + LOG.error("Unknown previous message {}", previousMessage) + return null + } + } + + LOG.error("Unexpected payload size as response for {}", previousMessage) + return null + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/onetouch/OneTouchSupport.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/onetouch/OneTouchSupport.kt new file mode 100644 index 0000000000..63cee5a76b --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/onetouch/OneTouchSupport.kt @@ -0,0 +1,453 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.onetouch + +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCharacteristic +import android.content.Context +import android.content.Intent +import android.widget.Toast +import nodomain.freeyourgadget.gadgetbridge.BuildConfig +import nodomain.freeyourgadget.gadgetbridge.GBApplication +import nodomain.freeyourgadget.gadgetbridge.R +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdateDeviceInfo +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdateDeviceState +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo +import nodomain.freeyourgadget.gadgetbridge.devices.GlucoseSampleProvider +import nodomain.freeyourgadget.gadgetbridge.entities.GlucoseSample +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice +import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLESingleDeviceSupport +import nodomain.freeyourgadget.gadgetbridge.service.btle.GattService +import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder +import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.IntentListener +import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.deviceinfo.DeviceInfo +import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.deviceinfo.DeviceInfoProfile +import nodomain.freeyourgadget.gadgetbridge.util.GB +import nodomain.freeyourgadget.gadgetbridge.util.kotlin.withTransaction +import nodomain.freeyourgadget.gadgetbridge.util.notifications.GBProgressNotification +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.LinkedList +import java.util.Queue +import java.util.UUID + +class OneTouchSupport : AbstractBTLESingleDeviceSupport(LOG) { + private val deviceInfoProfile: DeviceInfoProfile + + private val reassemblyBuffer = ByteBuffer.allocate(1024).order(ByteOrder.LITTLE_ENDIAN) + + private var lastSentMessage: OneTouchMessage? = null + private val messageQueue: Queue = LinkedList() + private var expectedChunkCount: Int = 0 + + private var readings: MutableList = ArrayList() + private var latestSampleTimestamp: Long = 0L + private var deviceTimeOffset: Long? = null + private var fetchOffset: Short = 0 + private var fetchTotal: Short = 0 + private lateinit var transferNotification: GBProgressNotification + + init { + addSupportedService(UUID_SERVICE_ONETOUCH) + + val mListener = IntentListener { intent: Intent? -> + intent?.action?.let { action -> + when (action) { + DeviceInfoProfile.ACTION_DEVICE_INFO -> { + handleDeviceInfo(intent.getParcelableExtra(DeviceInfoProfile.EXTRA_DEVICE_INFO)!!) + } + } + } + } + + addSupportedService(GattService.UUID_SERVICE_DEVICE_INFORMATION) + deviceInfoProfile = DeviceInfoProfile(this) + deviceInfoProfile.addListener(mListener) + addSupportedProfile(deviceInfoProfile) + } + + override fun setContext( + gbDevice: GBDevice, + btAdapter: BluetoothAdapter, + context: Context + ) { + super.setContext(gbDevice, btAdapter, context) + transferNotification = GBProgressNotification(context, GB.NOTIFICATION_CHANNEL_ID_TRANSFER) + } + + override fun useAutoConnect(): Boolean { + return false + } + + override fun initializeDevice(builder: TransactionBuilder): TransactionBuilder { + reassemblyBuffer.clear() + readings.clear() + latestSampleTimestamp = 0L + fetchOffset = 0 + fetchTotal = 0 + lastSentMessage = null + + builder.setDeviceState(GBDevice.State.INITIALIZING) + + deviceInfoProfile.requestDeviceInfo(builder) + + builder.notify(UUID_CHARACTERISTIC_ONETOUCH_READ, true) + + // Send the first message directly, queue the rest + val timeGet = OneTouchMessage.TimeGet + lastSentMessage = timeGet + builder.write( + UUID_CHARACTERISTIC_ONETOUCH_WRITE, + *timeGet.encode() + ) + messageQueue.add(OneTouchMessage.ThresholdHighGet) + messageQueue.add(OneTouchMessage.ThresholdLowGet) + + return builder + } + + override fun onSetTime() { + // This should be deliberate by the user, and after we sync existing samples, to avoid + // messing up the device time + LOG.warn("Ignoring set time request") + //messageQueue.add(OneTouchMessage.TimeSet(Calendar.getInstance().timeInMillis)) + } + + override fun onSendConfiguration(config: String) { + // FIXME: Device currently ignores configuration changes + if (!BuildConfig.DEBUG) { + LOG.warn("Ignoring configuration change in non-debug build") + return + } + + when (config) { + DeviceSettingsPreferenceConst.PREF_GLUCOSE_THRESHOLD_HIGH -> { + queueMessage(OneTouchMessage.ThresholdHighSet( + devicePrefs.getInt(DeviceSettingsPreferenceConst.PREF_GLUCOSE_THRESHOLD_HIGH, 180) + )) + } + + DeviceSettingsPreferenceConst.PREF_GLUCOSE_THRESHOLD_LOW -> { + queueMessage(OneTouchMessage.ThresholdLowSet( + devicePrefs.getInt(DeviceSettingsPreferenceConst.PREF_GLUCOSE_THRESHOLD_LOW, 70) + )) + } + + else -> super.onSendConfiguration(config) + } + } + + override fun onFetchRecordedData(dataTypes: Int) { + if (deviceTimeOffset == null) { + // Should never happen at this point + LOG.warn("Time offset is null, requesting it first") + queueMessage(OneTouchMessage.TimeGet) + } else { + queueMessage(OneTouchMessage.ReadingCountGet) + } + } + + override fun onCharacteristicChanged( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray + ): Boolean { + if (characteristic.uuid == UUID_CHARACTERISTIC_ONETOUCH_READ) { + handleChunk(value) + return true + } + + if (super.onCharacteristicChanged(gatt, characteristic, value)) { + return true + } + + LOG.warn("Unhandled characteristic changed: {} {}", characteristic.uuid, GB.hexdump(value)) + return false + } + + private fun handleChunk(data: ByteArray) { + if (data.isEmpty()) return + + val firstByte = data[0].toInt() and 0xFF + val idx = firstByte and 0x3F + + // ACK + if ((firstByte and BITMASK_ACK) != 0) { + LOG.debug("Received ACK for chunk {}", idx) + sendNextMessage() + return + } + + // Send ACK for this chunk. This might be too early, but we don't know how to NACK + sendAck(idx) + + // Chunk (but not the first one) + if ((firstByte and BITMASK_CHUNK) != 0) { + LOG.debug("Received chunk {} of {}", idx + 1, expectedChunkCount) + + // Append the chunk (without the first byte) + if (data.size > 1) { + reassemblyBuffer.put(data.copyOfRange(1, data.size)) + } + + // Check if we have all chunks + if (idx == expectedChunkCount - 1) { + // Reassemble all chunks + handlePacket(reassemblyBuffer.array().copyOfRange(0, reassemblyBuffer.position())) + + // Reset reassembly state + reassemblyBuffer.clear() + expectedChunkCount = 0 + } + + return + } + + // First of multiple chunks + if (firstByte > 1) { + LOG.debug("Starting chunked transfer: {} chunks", firstByte) + expectedChunkCount = firstByte + reassemblyBuffer.clear() + reassemblyBuffer.put(data) + return + } + + // Single-chunk packet + if (firstByte == 1) { + reassemblyBuffer.clear() + handlePacket(data) + return + } + } + + private fun handlePacket(packet: ByteArray) { + LOG.debug("Parsing full packet: {}", GB.hexdump(packet)) + + val lastCommand = lastSentMessage + if (lastCommand == null) { + LOG.error("Got return value, but no last message") + sendNextMessage() + return + } + + when (val message = OneTouchMessage.decode(packet, lastCommand)) { + is OneTouchMessage.TimeRet -> { + deviceTimeOffset = System.currentTimeMillis() - message.timestampMillis + LOG.info("Device time: {}, offset={}", message.timestampMillis, deviceTimeOffset) + queueMessage(OneTouchMessage.ReadingCountGet) + } + + is OneTouchMessage.ReadingRet -> { + val sampleOffset = deviceTimeOffset + if (sampleOffset == null) { + LOG.error("Unable to process sample - device time offset is not known") + return + } else { + val actualTimestamp = message.timestampMillis + sampleOffset + LOG.debug( + "Glucose reading offset={}, index={}: {} mg/dL at {} ({} adjusted)", + message.offset, + message.index, + message.value, + message.timestampMillis, + actualTimestamp + ) + + if (actualTimestamp > latestSampleTimestamp + 2000L) { + // 2s offset to avoid duplicates since device time offset can vary by a few milliseconds + // New sample + readings.add(message) + } + fetchOffset++ + + if (fetchOffset < fetchTotal && actualTimestamp > latestSampleTimestamp) { + fetchReading(fetchOffset) + } else { + LOG.debug("Fetch finished at {}/{}, {}, {}", fetchOffset, fetchTotal, actualTimestamp, latestSampleTimestamp) + transferNotification.finish() + device.unsetBusyTask() + device.sendDeviceUpdateIntent(context) + storeReadings() + } + } + } + + is OneTouchMessage.ReadingCountRet -> { + LOG.info("Reading count: {}", message.count) + + latestSampleTimestamp = getLatestSample() + fetchOffset = 0 + fetchTotal = message.count + + LOG.debug("Starting fetch up to {}", latestSampleTimestamp) + + device.setBusyTask( + R.string.busy_task_fetch_glucose_data, + context + ) + device.sendDeviceUpdateIntent(context) + + transferNotification.start(R.string.busy_task_fetch_glucose_data, 0) + + fetchReading(0) + } + + is OneTouchMessage.ThresholdLowRet -> { + LOG.info("Glucose low threshold: {}", message.threshold) + evaluateGBDeviceEvent(GBDeviceEventUpdatePreferences( + DeviceSettingsPreferenceConst.PREF_GLUCOSE_THRESHOLD_LOW, + message.threshold.toString() + )) + evaluateGBDeviceEvent(GBDeviceEventUpdateDeviceState(GBDevice.State.INITIALIZED)) + } + + is OneTouchMessage.ThresholdHighRet -> { + LOG.info("Glucose high limit: {}", message.threshold) + evaluateGBDeviceEvent(GBDeviceEventUpdatePreferences( + DeviceSettingsPreferenceConst.PREF_GLUCOSE_THRESHOLD_HIGH, + message.threshold.toString() + )) + } + + else -> { + LOG.warn("Unhandled message {}", message) + } + } + + lastSentMessage = null + sendNextMessage() + } + + private fun getLatestSample(): Long { + try { + GBApplication.acquireDB().use { handler -> + val session = handler.getDaoSession() + val sampleProvider = GlucoseSampleProvider(device, session) + return sampleProvider.latestSample?.timestamp ?: 0L + } + } catch (e: Exception) { + GB.toast(context, "Error getting latest timestamp", Toast.LENGTH_LONG, GB.ERROR, e) + } + + return 0L + } + + private fun sendAck(chunkIndex: Int) { + withTransaction("ack_$chunkIndex") { builder -> + builder.write( + UUID_CHARACTERISTIC_ONETOUCH_WRITE, + (BITMASK_ACK or chunkIndex).toByte() + ) + } + } + + private fun queueMessage(message: OneTouchMessage) { + messageQueue.add(message) + sendNextMessage() + } + + private fun fetchReading(offset: Short) { + LOG.debug("Queuing reading fetch at offset {}", offset) + queueMessage(OneTouchMessage.ReadingGet(offset)) + } + + private fun storeReadings() { + if (readings.isEmpty()) { + LOG.debug("No new readings to store") + return + } + + val sampleOffset = deviceTimeOffset + if (sampleOffset == null) { + LOG.error("Unable to store samples - device time offset is not known") + return + } + + LOG.debug("Storing {} readings, using device time offset {}ms", readings.size, sampleOffset) + + val samples = readings.map { + val sample = GlucoseSample() + sample.timestamp = it.timestampMillis + sampleOffset + sample.valueMgDl = it.value.toDouble() + return@map sample + }.toList() + + try { + GBApplication.acquireDB().use { handler -> + val session = handler.getDaoSession() + val sampleProvider = GlucoseSampleProvider(device, session) + sampleProvider.persistForDevice(context, device, samples) + } + } catch (e: Exception) { + GB.toast(context, "Error storing glucose readings", Toast.LENGTH_LONG, GB.ERROR, e) + } + + GB.signalActivityDataFinish(device) + + readings.clear() + } + + private fun sendNextMessage() { + lastSentMessage?.let { + LOG.debug("Not sending next message - already waiting for {}", it) + return + } + + val message = messageQueue.poll() + if (message == null) { + LOG.debug("No messages found in queue") + return + } + + withTransaction("send ${message.javaClass.simpleName}") { builder -> + builder.write(UUID_CHARACTERISTIC_ONETOUCH_WRITE, *message.encode()) + } + + lastSentMessage = message + } + + private fun handleDeviceInfo(deviceInfo: DeviceInfo) { + LOG.debug("Device info: {}", deviceInfo) + + val versionCmd = GBDeviceEventVersionInfo() + + if (deviceInfo.hardwareRevision != null) { + versionCmd.hwVersion = deviceInfo.hardwareRevision + } + + if (deviceInfo.firmwareRevision != null) { + versionCmd.fwVersion = deviceInfo.firmwareRevision + versionCmd.fwVersion2 = deviceInfo.softwareRevision + } else if (deviceInfo.softwareRevision != null) { + versionCmd.fwVersion = deviceInfo.softwareRevision + } + + handleGBDeviceEvent(versionCmd) + + if (deviceInfo.manufacturerName != null) { + handleGBDeviceEvent(GBDeviceEventUpdateDeviceInfo("MANUFACTURER: ", deviceInfo.manufacturerName)) + } + + if (deviceInfo.modelNumber != null) { + handleGBDeviceEvent(GBDeviceEventUpdateDeviceInfo("MODEL: ", deviceInfo.modelNumber)) + } + + if (deviceInfo.serialNumber != null) { + handleGBDeviceEvent(GBDeviceEventUpdateDeviceInfo("SERIAL: ", deviceInfo.serialNumber)) + } + } + + companion object { + private val LOG: Logger = LoggerFactory.getLogger(OneTouchSupport::class.java) + + val UUID_SERVICE_ONETOUCH: UUID = UUID.fromString("af9df7a1-e595-11e3-96b4-0002a5d5c51b") + val UUID_CHARACTERISTIC_ONETOUCH_WRITE: UUID = UUID.fromString("af9df7a2-e595-11e3-96b4-0002a5d5c51b") + val UUID_CHARACTERISTIC_ONETOUCH_READ: UUID = UUID.fromString("af9df7a3-e595-11e3-96b4-0002a5d5c51b") + + private const val BITMASK_ACK = 0x80 + private const val BITMASK_CHUNK = 0x40 + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/CheckSums.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/CheckSums.java index 373137a944..aa96ba4ac9 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/CheckSums.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/CheckSums.java @@ -158,11 +158,14 @@ public class CheckSums { 27655, 23652, 19525, 15522, 11395, 7392, 3265, 61215, 65342, 53085, 57212, 44955, 49082, 36825, 40952, 28183, 32310, 20053, 24180, 11923, 16050, 3793, 7920}; - // // https://github.com/ThePBone/GalaxyBudsClient/blob/master/GalaxyBudsClient/Utils/CRC16.cs - public static int crc16_ccitt(byte[] data) { + public static int crc16_ccitt(final byte[] data) { + return crc16_ccitt(data, 0, data.length, 0); + } - int i2 = 0; - for (int i3 = 0; i3 < data.length; i3++) + // // https://github.com/ThePBone/GalaxyBudsClient/blob/master/GalaxyBudsClient/Utils/CRC16.cs + public static int crc16_ccitt(final byte[] data, final int offset, final int length, final int initialValue) { + int i2 = initialValue; + for (int i3 = offset; i3 < length; i3++) i2 = Crc16Tab[((i2 >> 8) ^ data[i3]) & 255] ^ (i2 << 8); return 65535 & i2; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/kotlin/TransactionBuilderExtensions.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/kotlin/TransactionBuilderExtensions.kt new file mode 100644 index 0000000000..6aa45a5146 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/kotlin/TransactionBuilderExtensions.kt @@ -0,0 +1,12 @@ +package nodomain.freeyourgadget.gadgetbridge.util.kotlin + +import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLESingleDeviceSupport + +inline fun AbstractBTLESingleDeviceSupport.withTransaction( + name: String, + block: (nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder) -> Unit +) { + val builder = createTransactionBuilder(name) + block(builder) + builder.queue() +} diff --git a/app/src/main/res/drawable/ic_glucose.xml b/app/src/main/res/drawable/ic_glucose.xml new file mode 100644 index 0000000000..ef96479db2 --- /dev/null +++ b/app/src/main/res/drawable/ic_glucose.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 937b922c3c..ef2f513690 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -757,6 +757,7 @@ File logging initialization failed, writing log files is currently not available. Restart the application to attempt to initialize the log files again. Initializing Fetching activity data + Fetching glucose data Fetching sports summaries Fetching sports details Fetching sports details was interrupted @@ -930,6 +931,8 @@ High heart rate alert threshold High activity heart rate alert threshold Low heart rate alert threshold + High glucose threshold + Low glucose threshold Stress monitoring Monitor stress level while resting Relaxation reminder @@ -1801,6 +1804,7 @@ Very High Daily Load %1$d bpm + %1$d mg/dL %1$,.2f km Gained Lost @@ -4386,6 +4390,7 @@ Could not open URL Authentication settings Shokz OpenSwim Pro + OneTouch Glucose Meter Standard Playback mode Normal diff --git a/app/src/main/res/xml/devicesettings_glucose_limits.xml b/app/src/main/res/xml/devicesettings_glucose_limits.xml new file mode 100644 index 0000000000..badbf7d4a2 --- /dev/null +++ b/app/src/main/res/xml/devicesettings_glucose_limits.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/onetouch/OneTouchMessageTest.kt b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/onetouch/OneTouchMessageTest.kt new file mode 100644 index 0000000000..6537b34a1d --- /dev/null +++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/onetouch/OneTouchMessageTest.kt @@ -0,0 +1,515 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.onetouch + +import nodomain.freeyourgadget.gadgetbridge.test.TestBase +import nodomain.freeyourgadget.gadgetbridge.util.GB +import org.junit.Assert.* +import org.junit.Test +import java.util.Calendar +import java.util.TimeZone + +class OneTouchMessageTest: TestBase() { + + // ==================== TimeGet Tests ==================== + @Test + fun testTimeGetEncode() { + val message = OneTouchMessage.TimeGet + val encoded = message.encode() + val expected = GB.hexStringToByteArray("0102090004200203F9C3") + + assertArrayEquals(expected, encoded) + } + + // ==================== ReadingCountGet Tests ==================== + @Test + fun testReadingCountGetEncode() { + val message = OneTouchMessage.ReadingCountGet + val encoded = message.encode() + val expected = GB.hexStringToByteArray("01020900042700030B20") + + assertArrayEquals(expected, encoded) + } + + // ==================== ThresholdLowGet Tests ==================== + @Test + fun testThresholdLowGetEncode() { + val message = OneTouchMessage.ThresholdLowGet + val encoded = message.encode() + val expected = GB.hexStringToByteArray("01020A00040A020903E048") + + assertArrayEquals(expected, encoded) + } + + // ==================== ThresholdHighGet Tests ==================== + @Test + fun testThresholdHighGetEncode() { + val message = OneTouchMessage.ThresholdHighGet + val encoded = message.encode() + val expected = GB.hexStringToByteArray("01020A00040A020A03B31D") + + assertArrayEquals(expected, encoded) + } + + // ==================== ThresholdLowSet Tests ==================== + @Test + fun testThresholdLowSetEncode() { + val message = OneTouchMessage.ThresholdLowSet(70) + val encoded = message.encode() + val expected = GB.hexStringToByteArray("01020E00030A010946000000035C71") + + assertArrayEquals(expected, encoded) + } + + // ==================== ThresholdHighSet Tests ==================== + @Test + fun testThresholdHighSetEncode() { + val message = OneTouchMessage.ThresholdHighSet(180) + val encoded = message.encode() + val expected = GB.hexStringToByteArray("01020E00030A010AB40000000369C4") + + assertArrayEquals(expected, encoded) + } + + // ==================== ReadingGet Tests ==================== + @Test + fun testReadingGetEncode_offset0() { + val message = OneTouchMessage.ReadingGet(0) + val encoded = message.encode() + val expected = GB.hexStringToByteArray("01020C0004310200000003A955") + + assertArrayEquals(expected, encoded) + } + + @Test + fun testReadingGetEncode_offset1() { + val message = OneTouchMessage.ReadingGet(1) + val encoded = message.encode() + val expected = GB.hexStringToByteArray("01020C00043102010000031D23") + + assertArrayEquals(expected, encoded) + } + + @Test + fun testReadingGetEncode_offset2() { + val message = OneTouchMessage.ReadingGet(2) + val encoded = message.encode() + val expected = GB.hexStringToByteArray("01020C0004310202000003C1B8") + + assertArrayEquals(expected, encoded) + } + + @Test + fun testReadingGetEncode_offset45() { + val message = OneTouchMessage.ReadingGet(45) + val encoded = message.encode() + val expected = GB.hexStringToByteArray("01020C000431022D000003615B") + + assertArrayEquals(expected, encoded) + } + + @Test + fun testReadingGetEncode_offset46() { + val message = OneTouchMessage.ReadingGet(46) + val encoded = message.encode() + val expected = GB.hexStringToByteArray("01020C000431022E000003BDC0") + + assertArrayEquals(expected, encoded) + } + + @Test + fun testReadingGetEncode_offset47() { + val message = OneTouchMessage.ReadingGet(47) + val encoded = message.encode() + val expected = GB.hexStringToByteArray("01020C000431022F00000309B6") + + assertArrayEquals(expected, encoded) + } + + @Test + fun testReadingGetEncode_offset48() { + val message = OneTouchMessage.ReadingGet(48) + val encoded = message.encode() + val expected = GB.hexStringToByteArray("01020C00043102300000034079") + + assertArrayEquals(expected, encoded) + } + + @Test + fun testReadingGetEncode_offset61() { + val message = OneTouchMessage.ReadingGet(61) + val encoded = message.encode() + val expected = GB.hexStringToByteArray("01020C000431023D000003C640") + + assertArrayEquals(expected, encoded) + } + + // ==================== TimeSet Tests ==================== + @Test + fun testTimeSetEncode_2026_01_04_22_36_20() { + // 2026-01-04 22:36:20 + val calendar = Calendar.getInstance() + calendar.timeZone = TimeZone.getTimeZone("UTC") + calendar.set(2026, Calendar.JANUARY, 4, 22, 36, 20) + calendar.set(Calendar.MILLISECOND, 0) + + val message = OneTouchMessage.TimeSet(calendar.timeInMillis) + val encoded = message.encode() + val expected = GB.hexStringToByteArray("01020D00042001E4A7ED30037B06") + + assertArrayEquals(expected, encoded) + } + + @Test + fun testTimeSetEncode_2026_01_04_23_36_13() { + // 2026-01-04 23:36:13 + val calendar = Calendar.getInstance() + calendar.timeZone = TimeZone.getTimeZone("UTC") + calendar.set(2026, Calendar.JANUARY, 4, 23, 36, 13) + calendar.set(Calendar.MILLISECOND, 0) + + val message = OneTouchMessage.TimeSet(calendar.timeInMillis) + val encoded = message.encode() + val expected = GB.hexStringToByteArray("01020D00042001EDB5ED3003C858") + + assertArrayEquals(expected, encoded) + } + + // ==================== ReadingRet Decode Tests ==================== + @Test + fun testReadingRetDecode_2018_12_18() { + // ReadingRet: 2018-12-18 18:13:00 - 92 + val packet = GB.hexStringToByteArray("0202180004063D000003002CF5AB235C0000002D0B0003F84D") + val message = OneTouchMessage.decode(packet, OneTouchMessage.ReadingGet(0)) + + assertNotNull(message) + assertTrue(message is OneTouchMessage.ReadingRet) + + val readingRet = message as OneTouchMessage.ReadingRet + assertEquals(92, readingRet.value) + + val cal = Calendar.getInstance() + cal.timeZone = TimeZone.getTimeZone("UTC") + cal.timeInMillis = readingRet.timestampMillis + assertEquals(2018, cal.get(Calendar.YEAR)) + assertEquals(Calendar.DECEMBER, cal.get(Calendar.MONTH)) + assertEquals(18, cal.get(Calendar.DAY_OF_MONTH)) + } + + @Test + fun testReadingRetDecode_2018_12_19() { + // ReadingRet: 2018-12-19 11:08:04 - 221 + val packet = GB.hexStringToByteArray("0202180004063A0000060014E3AC23DD000000FD0A00039866") + val message = OneTouchMessage.decode(packet, OneTouchMessage.ReadingGet(0)) + + assertNotNull(message) + assertTrue(message is OneTouchMessage.ReadingRet) + + val readingRet = message as OneTouchMessage.ReadingRet + assertEquals(221, readingRet.value) + + val cal = Calendar.getInstance() + cal.timeZone = TimeZone.getTimeZone("UTC") + cal.timeInMillis = readingRet.timestampMillis + assertEquals(2018, cal.get(Calendar.YEAR)) + assertEquals(Calendar.DECEMBER, cal.get(Calendar.MONTH)) + assertEquals(19, cal.get(Calendar.DAY_OF_MONTH)) + } + + @Test + fun testReadingRetDecode_2018_12_23() { + // ReadingRet: 2018-12-23 18:25:02 - 95 + val packet = GB.hexStringToByteArray("0202180004063400000C007E8FB2235F000000F40A00039B01") + val message = OneTouchMessage.decode(packet, OneTouchMessage.ReadingGet(0)) + + assertNotNull(message) + assertTrue(message is OneTouchMessage.ReadingRet) + + val readingRet = message as OneTouchMessage.ReadingRet + assertEquals(95, readingRet.value) + + val cal = Calendar.getInstance() + cal.timeZone = TimeZone.getTimeZone("UTC") + cal.timeInMillis = readingRet.timestampMillis + assertEquals(2018, cal.get(Calendar.YEAR)) + assertEquals(Calendar.DECEMBER, cal.get(Calendar.MONTH)) + assertEquals(23, cal.get(Calendar.DAY_OF_MONTH)) + } + + @Test + fun testReadingRetDecode_2019_01_01() { + // ReadingRet: 2019-01-01 12:01:06 - 196 + val packet = GB.hexStringToByteArray("02021800040624000027000213BE23C4000000B70A0003AB5D") + val message = OneTouchMessage.decode(packet, OneTouchMessage.ReadingGet(0)) + + assertNotNull(message) + assertTrue(message is OneTouchMessage.ReadingRet) + + val readingRet = message as OneTouchMessage.ReadingRet + assertEquals(196, readingRet.value) + + val cal = Calendar.getInstance() + cal.timeZone = TimeZone.getTimeZone("UTC") + cal.timeInMillis = readingRet.timestampMillis + assertEquals(2019, cal.get(Calendar.YEAR)) + assertEquals(Calendar.JANUARY, cal.get(Calendar.MONTH)) + assertEquals(1, cal.get(Calendar.DAY_OF_MONTH)) + } + + @Test + fun testReadingRetDecode_2019_02_09() { + // ReadingRet: 2019-02-09 14:11:20 - 226 + val packet = GB.hexStringToByteArray("0202180004061D00003500089CF123E2000000320B0003F14E") + val message = OneTouchMessage.decode(packet, OneTouchMessage.ReadingGet(0)) + + assertNotNull(message) + assertTrue(message is OneTouchMessage.ReadingRet) + + val readingRet = message as OneTouchMessage.ReadingRet + assertEquals(226, readingRet.value) + + val cal = Calendar.getInstance() + cal.timeZone = TimeZone.getTimeZone("UTC") + cal.timeInMillis = readingRet.timestampMillis + assertEquals(2019, cal.get(Calendar.YEAR)) + assertEquals(Calendar.FEBRUARY, cal.get(Calendar.MONTH)) + assertEquals(9, cal.get(Calendar.DAY_OF_MONTH)) + } + + @Test + fun testReadingRetDecode_2019_03_20() { + // ReadingRet: 2019-03-20 16:14:22 - 105 + val packet = GB.hexStringToByteArray("0202180004061500003D005E232524690000003F0B0003F432") + val message = OneTouchMessage.decode(packet, OneTouchMessage.ReadingGet(0)) + + assertNotNull(message) + assertTrue(message is OneTouchMessage.ReadingRet) + + val readingRet = message as OneTouchMessage.ReadingRet + assertEquals(105, readingRet.value) + + val cal = Calendar.getInstance() + cal.timeZone = TimeZone.getTimeZone("UTC") + cal.timeInMillis = readingRet.timestampMillis + assertEquals(2019, cal.get(Calendar.YEAR)) + assertEquals(Calendar.MARCH, cal.get(Calendar.MONTH)) + assertEquals(20, cal.get(Calendar.DAY_OF_MONTH)) + } + + @Test + fun testReadingRetDecode_2019_04_05() { + // ReadingRet: 2019-04-05 09:09:02 - 98 + val packet = GB.hexStringToByteArray("0202180004060F000043009EC93924620000002F0B000393B6") + val message = OneTouchMessage.decode(packet, OneTouchMessage.ReadingGet(0)) + + assertNotNull(message) + assertTrue(message is OneTouchMessage.ReadingRet) + + val readingRet = message as OneTouchMessage.ReadingRet + assertEquals(98, readingRet.value) + + val cal = Calendar.getInstance() + cal.timeZone = TimeZone.getTimeZone("UTC") + cal.timeInMillis = readingRet.timestampMillis + assertEquals(2019, cal.get(Calendar.YEAR)) + assertEquals(Calendar.APRIL, cal.get(Calendar.MONTH)) + assertEquals(5, cal.get(Calendar.DAY_OF_MONTH)) + } + + // ==================== TimeRet Decode Tests ==================== + @Test + fun testTimeRetDecode_2026_01_04_18_18_22() { + // TimeRet: 2026-01-04 18:18:22 + val packet = GB.hexStringToByteArray("01020C0004066E6BED3003AB80") + val message = OneTouchMessage.decode(packet, OneTouchMessage.TimeGet) + + assertNotNull(message) + assertTrue(message is OneTouchMessage.TimeRet) + + val timeRet = message as OneTouchMessage.TimeRet + val cal = Calendar.getInstance() + cal.timeZone = TimeZone.getTimeZone("UTC") + cal.timeInMillis = timeRet.timestampMillis + assertEquals(2026, cal.get(Calendar.YEAR)) + assertEquals(Calendar.JANUARY, cal.get(Calendar.MONTH)) + assertEquals(4, cal.get(Calendar.DAY_OF_MONTH)) + assertEquals(18, cal.get(Calendar.HOUR_OF_DAY)) + assertEquals(18, cal.get(Calendar.MINUTE)) + assertEquals(22, cal.get(Calendar.SECOND)) + } + + @Test + fun testTimeRetDecode_2026_01_04_18_19_24() { + // TimeRet: 2026-01-04 18:19:24 + val packet = GB.hexStringToByteArray("01020C000406AC6BED300390F7") + val message = OneTouchMessage.decode(packet, OneTouchMessage.TimeGet) + + assertNotNull(message) + assertTrue(message is OneTouchMessage.TimeRet) + + val timeRet = message as OneTouchMessage.TimeRet + val cal = Calendar.getInstance() + cal.timeZone = TimeZone.getTimeZone("UTC") + cal.timeInMillis = timeRet.timestampMillis + assertEquals(2026, cal.get(Calendar.YEAR)) + assertEquals(Calendar.JANUARY, cal.get(Calendar.MONTH)) + assertEquals(4, cal.get(Calendar.DAY_OF_MONTH)) + assertEquals(18, cal.get(Calendar.HOUR_OF_DAY)) + assertEquals(19, cal.get(Calendar.MINUTE)) + assertEquals(24, cal.get(Calendar.SECOND)) + } + + @Test + fun testTimeRetDecode_2026_01_04_18_28_25() { + // TimeRet: 2026-01-04 18:28:25 + val packet = GB.hexStringToByteArray("01020C000406C96DED300382EA") + val message = OneTouchMessage.decode(packet, OneTouchMessage.TimeGet) + + assertNotNull(message) + assertTrue(message is OneTouchMessage.TimeRet) + + val timeRet = message as OneTouchMessage.TimeRet + val cal = Calendar.getInstance() + cal.timeZone = TimeZone.getTimeZone("UTC") + cal.timeInMillis = timeRet.timestampMillis + assertEquals(2026, cal.get(Calendar.YEAR)) + assertEquals(Calendar.JANUARY, cal.get(Calendar.MONTH)) + assertEquals(4, cal.get(Calendar.DAY_OF_MONTH)) + assertEquals(18, cal.get(Calendar.HOUR_OF_DAY)) + assertEquals(28, cal.get(Calendar.MINUTE)) + assertEquals(25, cal.get(Calendar.SECOND)) + } + + @Test + fun testTimeRetDecode_2026_01_04_18_29_14() { + // TimeRet: 2026-01-04 18:29:14 + val packet = GB.hexStringToByteArray("01020C000406FA6DED3003BE08") + val message = OneTouchMessage.decode(packet, OneTouchMessage.TimeGet) + + assertNotNull(message) + assertTrue(message is OneTouchMessage.TimeRet) + + val timeRet = message as OneTouchMessage.TimeRet + val cal = Calendar.getInstance() + cal.timeZone = TimeZone.getTimeZone("UTC") + cal.timeInMillis = timeRet.timestampMillis + assertEquals(2026, cal.get(Calendar.YEAR)) + assertEquals(Calendar.JANUARY, cal.get(Calendar.MONTH)) + assertEquals(4, cal.get(Calendar.DAY_OF_MONTH)) + assertEquals(18, cal.get(Calendar.HOUR_OF_DAY)) + assertEquals(29, cal.get(Calendar.MINUTE)) + assertEquals(14, cal.get(Calendar.SECOND)) + } + + @Test + fun testTimeRetDecode_2026_01_04_22_36_49() { + // TimeRet: 2026-01-04 22:36:49 + val packet = GB.hexStringToByteArray("01020C00040601A8ED3003F6D4") + val message = OneTouchMessage.decode(packet, OneTouchMessage.TimeGet) + + assertNotNull(message) + assertTrue(message is OneTouchMessage.TimeRet) + + val timeRet = message as OneTouchMessage.TimeRet + val cal = Calendar.getInstance() + cal.timeZone = TimeZone.getTimeZone("UTC") + cal.timeInMillis = timeRet.timestampMillis + assertEquals(2026, cal.get(Calendar.YEAR)) + assertEquals(Calendar.JANUARY, cal.get(Calendar.MONTH)) + assertEquals(4, cal.get(Calendar.DAY_OF_MONTH)) + assertEquals(22, cal.get(Calendar.HOUR_OF_DAY)) + assertEquals(36, cal.get(Calendar.MINUTE)) + assertEquals(49, cal.get(Calendar.SECOND)) + } + + // ==================== ThresholdHighRet Decode Tests ==================== + @Test + fun testThresholdHighRetDecode_180() { + // ThresholdHighRet: 180 + val packet = GB.hexStringToByteArray("01020C000406B400000003DF51") + val message = OneTouchMessage.decode(packet, OneTouchMessage.ThresholdHighGet) + + assertNotNull(message) + assertTrue(message is OneTouchMessage.ThresholdHighRet) + + val thresholdRet = message as OneTouchMessage.ThresholdHighRet + assertEquals(180, thresholdRet.threshold) + } + + @Test + fun testThresholdHighRetDecode_181() { + // ThresholdHighRet: 181 + val packet = GB.hexStringToByteArray("01020C000406B5000000038EFB") + val message = OneTouchMessage.decode(packet, OneTouchMessage.ThresholdHighGet) + + assertNotNull(message) + assertTrue(message is OneTouchMessage.ThresholdHighRet) + + val thresholdRet = message as OneTouchMessage.ThresholdHighRet + assertEquals(181, thresholdRet.threshold) + } + + // ==================== ThresholdLowRet Decode Tests ==================== + @Test + fun testThresholdLowRetDecode_69() { + // ThresholdLowRet: 69 + val packet = GB.hexStringToByteArray("01020C0004064500000003D8C4") + val message = OneTouchMessage.decode(packet, OneTouchMessage.ThresholdLowGet) + + assertNotNull(message) + assertTrue(message is OneTouchMessage.ThresholdLowRet) + + val thresholdRet = message as OneTouchMessage.ThresholdLowRet + assertEquals(69, thresholdRet.threshold) + } + + @Test + fun testThresholdLowRetDecode_70() { + // ThresholdLowRet: 70 + val packet = GB.hexStringToByteArray("01020C00040646000000030A2A") + val message = OneTouchMessage.decode(packet, OneTouchMessage.ThresholdLowGet) + + assertNotNull(message) + assertTrue(message is OneTouchMessage.ThresholdLowRet) + + val thresholdRet = message as OneTouchMessage.ThresholdLowRet + assertEquals(70, thresholdRet.threshold) + } + + // ==================== ReadingCountRet Decode Tests ==================== + @Test + fun testReadingCountRetDecode_62() { + // ReadingCountRet: 62 + val packet = GB.hexStringToByteArray("01020A0004063E00038E0D") + val message = OneTouchMessage.decode(packet, OneTouchMessage.ReadingCountGet) + + assertNotNull(message) + assertTrue(message is OneTouchMessage.ReadingCountRet) + + val countRet = message as OneTouchMessage.ReadingCountRet + assertEquals(62, countRet.count) + } + + // ==================== Invalid Packet Tests ==================== + @Test + fun testDecodeInvalidPacket_tooShort() { + val packet = GB.hexStringToByteArray("010203") + val message = OneTouchMessage.decode(packet, OneTouchMessage.TimeGet) + + assertNull(message) + } + + @Test + fun testDecodeInvalidPacket_wrongHeader() { + val packet = GB.hexStringToByteArray("0003090004200203F9C3") + val message = OneTouchMessage.decode(packet, OneTouchMessage.TimeGet) + + assertNull(message) + } + + @Test + fun testDecodeInvalidPacket_wrongChecksum() { + val packet = GB.hexStringToByteArray("0102090004200203FFFF") + val message = OneTouchMessage.decode(packet, OneTouchMessage.TimeGet) + + assertNull(message) + } +}