From 42033aad18ebd771a5dc53a97f5d701ff410c15e Mon Sep 17 00:00:00 2001 From: Thomas Kuehne Date: Tue, 26 Aug 2025 00:00:00 +0200 Subject: [PATCH] ultrahuman: redesign device support --- .../UltrahumanBreathingActivity.java | 124 ++- .../ultrahuman/UltrahumanConstants.java | 16 +- .../UltrahumanDeviceCardAction.java | 14 +- .../UltrahumanDeviceCoordinator.java | 34 +- .../ultrahuman/UltrahumanExercise.java | 6 +- .../ultrahuman/UltrahumanExerciseData.java | 77 +- .../UltrahumanActivitySampleProvider.java | 34 +- .../ultrahuman/UltrahumanCharacteristic.java | 10 +- .../ultrahuman/UltrahumanDeviceSupport.java | 954 ++++++++++-------- .../devices/ultrahuman/UltrahumanWriteTime.kt | 39 + .../gadgetbridge/util/DateTimeUtils.java | 14 + app/src/main/res/drawable/ic_pulmonology.xml | 2 +- .../layout/activity_ultrahuman_breathing.xml | 231 +++-- app/src/main/res/values/strings.xml | 7 + 14 files changed, 983 insertions(+), 579 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/ultrahuman/UltrahumanWriteTime.kt diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanBreathingActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanBreathingActivity.java index 024212db09..36b7b3393e 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanBreathingActivity.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanBreathingActivity.java @@ -23,9 +23,10 @@ import android.content.Intent; import android.content.IntentFilter; import android.os.Bundle; import android.view.View; +import android.widget.Button; import android.widget.TextView; -import androidx.core.content.ContextCompat; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,12 +34,15 @@ import org.slf4j.LoggerFactory; import java.util.Calendar; import java.util.Locale; +import lineageos.weather.util.WeatherUtils; import nodomain.freeyourgadget.gadgetbridge.BuildConfig; +import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity; -import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils; +import nodomain.freeyourgadget.gadgetbridge.activities.SettingsActivity; +import nodomain.freeyourgadget.gadgetbridge.model.DeviceService; +import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs; -// TODO: verify GUI lifecycle // TODO: updates when multiple devices are active // TODO: polish and localize the GUI public class UltrahumanBreathingActivity extends AbstractGBActivity { @@ -47,25 +51,44 @@ public class UltrahumanBreathingActivity extends AbstractGBActivity { private TextView UiStatus; private TextView UiHR; private TextView UiHRV; + //private TextView UiMystery; private TextView UiTemp; private TextView UiTime; + private TextView UiBatGadget; + private boolean MetricUnits; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_ultrahuman_breathing); - findViewById(R.id.ultrahuman_breathing_start).setOnClickListener(new StartListener()); - findViewById(R.id.ultrahuman_breathing_stop).setOnClickListener(new StopListener()); - UiStatus = findViewById(R.id.ultrahuman_breathing_status); - UiHR = findViewById(R.id.ultrahuman_breathing_HR); - UiTemp = findViewById(R.id.ultrahuman_breathing_temp); - UiHRV = findViewById(R.id.ultrahuman_breathing_HRV); - UiTime = findViewById(R.id.ultrahuman_breathing_time); + findViewById(R.id.ultrahuman_exercise_start1).setOnClickListener(new ExerciseClick(UltrahumanExercise.BREATHING_START)); + findViewById(R.id.ultrahuman_exercise_stop1).setOnClickListener(new ExerciseClick(UltrahumanExercise.BREATHING_STOP)); + + UiStatus = findViewById(R.id.ultrahuman_exercise_type); + UiTime = findViewById(R.id.ultrahuman_exercise_time); + UiHR = findViewById(R.id.ultrahuman_exercise_hr_current); + UiTemp = findViewById(R.id.ultrahuman_exercise_temperature_current); + UiBatGadget = findViewById(R.id.ultrahuman_exercise_batGadget); + + + + UiHRV = findViewById(R.id.ultrahuman_exercise_hrv_current); + //UiMystery = findViewById(R.id.ultrahuman_breathing_mystery); IntentFilter filter = new IntentFilter(); + filter.addAction(DeviceService.ACTION_REALTIME_SAMPLES); filter.addAction(UltrahumanConstants.ACTION_EXERCISE_UPDATE); - ContextCompat.registerReceiver(getApplicationContext(), UpdateReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED); + LocalBroadcastManager.getInstance(getApplicationContext()).registerReceiver(UpdateReceiver, filter); + + final GBPrefs prefs = GBApplication.getPrefs(); + final String metric = getString(R.string.p_unit_metric); + final String unitSystem = prefs.getString(SettingsActivity.PREF_MEASUREMENT_SYSTEM, metric); + MetricUnits = unitSystem.equals(metric); + + Button temperatureUom = findViewById(R.id.ultrahuman_exercise_temperature_uom); + temperatureUom.setText(MetricUnits ? R.string.unit_celsius : R.string.unit_fahrenheit); changeExercise(UltrahumanExercise.CHECK); } @@ -80,55 +103,80 @@ public class UltrahumanBreathingActivity extends AbstractGBActivity { @Override public void onDestroy() { - getApplicationContext().unregisterReceiver(UpdateReceiver); + LocalBroadcastManager.getInstance(getApplicationContext()).unregisterReceiver(UpdateReceiver); super.onDestroy(); } - private class StartListener implements View.OnClickListener { - @Override - public void onClick(View v) { - changeExercise(UltrahumanExercise.BREATHING_START); + private class ExerciseClick implements View.OnClickListener { + final UltrahumanExercise Exercise; + ExerciseClick(UltrahumanExercise exercise){ + Exercise = exercise; } - } - - private class StopListener implements View.OnClickListener { @Override public void onClick(View v) { - changeExercise(UltrahumanExercise.BREATHING_STOP); + changeExercise(Exercise); } } private class ExerciseUpdateReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { - UltrahumanExerciseData data = (UltrahumanExerciseData) intent.getSerializableExtra(UltrahumanConstants.EXTRA_EXERCISE); + UltrahumanExerciseData data = (UltrahumanExerciseData) intent.getSerializableExtra(DeviceService.EXTRA_REALTIME_SAMPLE); + if (data != null) { - String update = String.format(Locale.US, "b:%s%% t:%x", data.BatteryLevel, 0xFF & data.Exercise); - UiStatus.setText(update); + String exercise = String.format(Locale.ROOT, "[%02x]", 0xFF & data.Exercise); + UiStatus.setText(exercise); + + + if(data.BatteryLevel > -1) { + String batGadget = getString(R.string.ultrahuman_exercise_charge, data.BatteryLevel); + UiBatGadget.setText(batGadget); + } if (data.Timestamp > -1) { - // todo - display local time - final Calendar calendar = DateTimeUtils.getCalendarUTC(); + final Calendar calendar = Calendar.getInstance(); calendar.setTimeInMillis(data.Timestamp * 1000L); int hour = calendar.get(Calendar.HOUR_OF_DAY); int minute = calendar.get(Calendar.MINUTE); int second = calendar.get(Calendar.SECOND); - String time = String.format(Locale.US, "%02d:%02d:%02d", hour, minute, second); + + final String time = getString(R.string.ultrahuman_exercise_time_format, hour, minute, second); UiTime.setText(time); + + if (data.HR > -1) { + String hr = getString(R.string.ultrahuman_exercise_hr_format, data.HR); + UiHR.setText(hr); + } else { + UiHR.setText(""); + } + + if (data.Temperature > -1) { + double degree; + if (MetricUnits) { + degree = data.Temperature; + } else { + degree = WeatherUtils.celsiusToFahrenheit(data.Temperature); + } + + String temp = getString(R.string.ultrahuman_exercise_temperature_format, degree); + UiTemp.setText(temp); + } else { + UiTemp.setText(""); + } + + if (data.HRV > 0) { + String hrv = getString(R.string.ultrahuman_exercise_hrv_format, data.HRV); + UiHRV.setText(hrv); + } else if (data.MeasurementType != 0x72) { + UiHRV.setText(""); + } } - if (data.HR > -1) { - String hr = String.format(Locale.US, "%s bpm", data.HR); - UiHR.setText(hr); - } - if (data.HRV > 0) { - String hrv = String.format(Locale.US, "%s ms", data.HRV); - UiHRV.setText(hrv); - } - if (data.Temperature > -1) { - String temp = String.format(Locale.US, "%.3f °C", data.Temperature); - UiTemp.setText(temp); - } + /*if (data.Mystery != null) { + UiMystery.setText(data.Mystery + " ?"); + } else { + UiMystery.setText(""); + }*/ } } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanConstants.java index 1327320211..c61c3208d7 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanConstants.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanConstants.java @@ -33,8 +33,10 @@ public final class UltrahumanConstants { public static final byte OPERATION_ACTIVATE_AIRPLANE_MODE = 0x70; public static final byte OPERATION_BREATHING_START = 0x72; public static final byte OPERATION_BREATHING_STOP = 0x74; - public static final byte OPERATION_DISABLE_POWERSAVE = (byte) 0xD1; - public static final byte OPERATION_ENABLE_POWERSAVE = (byte) 0xD2; + public static final byte OPERATION_ENABLE_SPO2 = (byte) 0xD1; + public static final byte OPERATION_DISABLE_SPO2 = (byte) 0xD2; + public static final byte OPERATION_EXERCISE_START = 0x62; + public static final byte OPERATION_EXERCISE_STOP = 0x64; public static final byte OPERATION_GET_FIRST_RECORDING_NR = 0x07; public static final byte OPERATION_GET_LAST_RECORDING_NR = 0x08; public static final byte OPERATION_GET_RECORDINGS = 0x04; @@ -43,15 +45,15 @@ public final class UltrahumanConstants { public static final byte OPERATION_SETTIME = 0x02; public static final UUID UUID_SERVICE_DATA = UUID.fromString("86f66000-f706-58a0-95b2-1fb9261e4dc7"); - public static final UUID UUID_CHARACTERISTIC_DATA = UUID.fromString("86f66001-f706-58a0-95b2-1fb9261e4dc7"); + public static final UUID UUID_DATA = UUID.fromString("86f66001-f706-58a0-95b2-1fb9261e4dc7"); public static final UUID UUID_SERVICE_REQUEST = UUID.fromString("86f65000-f706-58a0-95b2-1fb9261e4dc7"); - public static final UUID UUID_CHARACTERISTIC_COMMAND = UUID.fromString("86f65001-f706-58a0-95b2-1fb9261e4dc7"); - public static final UUID UUID_CHARACTERISTIC_RESPONSE = UUID.fromString("86f65002-f706-58a0-95b2-1fb9261e4dc7"); + public static final UUID UUID_COMMAND = UUID.fromString("86f65001-f706-58a0-95b2-1fb9261e4dc7"); + public static final UUID UUID_RESPONSE = UUID.fromString("86f65002-f706-58a0-95b2-1fb9261e4dc7"); public static final UUID UUID_SERVICE_STATE = UUID.fromString("86f61000-f706-58a0-95b2-1fb9261e4dc7"); - public static final UUID UUID_CHARACTERISTIC_STATE = UUID.fromString("86f61001-f706-58a0-95b2-1fb9261e4dc7"); + public static final UUID UUID_STATE = UUID.fromString("86f61001-f706-58a0-95b2-1fb9261e4dc7"); public static final UUID UUID_SERVICE_TODO = UUID.fromString("86f63000-f706-58a0-95b2-1fb9261e4dc7"); - public static final UUID UUID_CHARACTERISTIC_TODO = UUID.fromString("86f63001-f706-58a0-95b2-1fb9261e4dc7"); + public static final UUID UUID_TODO = UUID.fromString("86f63001-f706-58a0-95b2-1fb9261e4dc7"); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanDeviceCardAction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanDeviceCardAction.java index 44fd09523f..cf664310e5 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanDeviceCardAction.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanDeviceCardAction.java @@ -20,6 +20,8 @@ package nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman; import android.content.Context; import android.content.Intent; +import androidx.annotation.Nullable; + import com.google.android.material.dialog.MaterialAlertDialogBuilder; import nodomain.freeyourgadget.gadgetbridge.BuildConfig; @@ -33,10 +35,12 @@ public class UltrahumanDeviceCardAction implements DeviceCardAction { private final int Description; private final int Question; + @Nullable private final String IntentAction; + @Nullable private final Class IntentClass; - public UltrahumanDeviceCardAction(int icon, int description, int question, String action) { + UltrahumanDeviceCardAction(int icon, int description, int question, String action) { Icon = icon; Description = description; Question = question; @@ -44,7 +48,7 @@ public class UltrahumanDeviceCardAction implements DeviceCardAction { IntentClass = null; } - public UltrahumanDeviceCardAction(int icon, int description, Class cls) { + UltrahumanDeviceCardAction(int icon, int description, Class cls) { Icon = icon; Description = description; Question = 0; @@ -64,7 +68,9 @@ public class UltrahumanDeviceCardAction implements DeviceCardAction { @Override public void onClick(GBDevice device, Context context) { - if (Question != 0) { + if (Question == 0) { + sendIntent(device, context); + } else { new MaterialAlertDialogBuilder(context) .setTitle(Description) .setMessage(Question) @@ -73,8 +79,6 @@ public class UltrahumanDeviceCardAction implements DeviceCardAction { -> sendIntent(device, context)) .setNegativeButton(android.R.string.no, null) .show(); - } else { - sendIntent(device, context); } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanDeviceCoordinator.java index 1e730dfd3b..a3294f2805 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanDeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanDeviceCoordinator.java @@ -34,6 +34,7 @@ import de.greenrobot.dao.Property; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings; import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCardAction; import nodomain.freeyourgadget.gadgetbridge.devices.GenericHeartRateSampleProvider; @@ -80,7 +81,7 @@ public class UltrahumanDeviceCoordinator extends AbstractBLEDeviceCoordinator { editor.putBoolean(GBPrefs.DEVICE_CONNECT_BACK, true); editor.putBoolean(GBPrefs.DEVICE_AUTO_RECONNECT, true); - // when the battery is low the gadget loses it's clock + // the gadget loses it's clock when the battery is low editor.putBoolean(DeviceSettingsPreferenceConst.PREF_TIME_SYNC, true); // O2 measurement with smart rings is still work in progress @@ -132,6 +133,13 @@ public class UltrahumanDeviceCoordinator extends AbstractBLEDeviceCoordinator { return UltrahumanDeviceSupport.class; } + @Override + public DeviceSpecificSettings getDeviceSpecificSettings(GBDevice device) { + final DeviceSpecificSettings settings = new DeviceSpecificSettings(); + settings.addRootScreen(R.xml.devicesettings_ultrahuman_air); + return settings; + } + @Override public String getManufacturer() { return "Ultrahuman Healthcare Pvt Ltd"; @@ -167,11 +175,6 @@ public class UltrahumanDeviceCoordinator extends AbstractBLEDeviceCoordinator { return Pattern.compile("^UH_[0-9A-F]{12}$"); } - @Override - public int[] getSupportedDeviceSpecificSettings(GBDevice device) { - return new int[]{R.xml.devicesettings_ultrahuman_air}; - } - @Override public TimeSampleProvider getTemperatureSampleProvider(GBDevice device, DaoSession session) { return new GenericTemperatureSampleProvider(device, session); @@ -188,7 +191,7 @@ public class UltrahumanDeviceCoordinator extends AbstractBLEDeviceCoordinator { } @Override - public boolean supportsActivityDataFetching(final GBDevice device) { + public boolean supportsActivityDataFetching(@NonNull final GBDevice device) { return true; } @@ -198,32 +201,37 @@ public class UltrahumanDeviceCoordinator extends AbstractBLEDeviceCoordinator { } @Override - public boolean supportsContinuousTemperature(final GBDevice device) { + public boolean supportsContinuousTemperature(@NonNull final GBDevice device) { return true; } @Override - public boolean supportsHeartRateMeasurement(GBDevice device) { + public boolean supportsHeartRateMeasurement(@NonNull GBDevice device) { return true; } @Override - public boolean supportsHrvMeasurement(final GBDevice device) { + public boolean supportsHrvMeasurement(@NonNull final GBDevice device) { return true; } @Override - public boolean supportsManualHeartRateMeasurement(final GBDevice device) { + public boolean supportsManualHeartRateMeasurement(@NonNull final GBDevice device) { return false; } + @Override + public boolean supportsRealtimeData(@NonNull GBDevice device) { + return true; + } + @Override public boolean supportsSleepMeasurement(@NonNull GBDevice device) { return false; } @Override - public boolean supportsSpo2(GBDevice device) { + public boolean supportsSpo2(@NonNull GBDevice device) { return true; } @@ -233,7 +241,7 @@ public class UltrahumanDeviceCoordinator extends AbstractBLEDeviceCoordinator { } @Override - public boolean supportsTemperatureMeasurement(final GBDevice device) { + public boolean supportsTemperatureMeasurement(@NonNull final GBDevice device) { return true; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanExercise.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanExercise.java index 34c5778189..e05b45ef1b 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanExercise.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanExercise.java @@ -18,9 +18,11 @@ package nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman; public enum UltrahumanExercise { - CHECK((byte) 0), + CHECK(UltrahumanConstants.OPERATION_CHECK_DATA), BREATHING_START(UltrahumanConstants.OPERATION_BREATHING_START), - BREATHING_STOP(UltrahumanConstants.OPERATION_BREATHING_STOP); + BREATHING_STOP(UltrahumanConstants.OPERATION_BREATHING_STOP), + EXERCISE_START(UltrahumanConstants.OPERATION_EXERCISE_START), + EXERCISE_STOP(UltrahumanConstants.OPERATION_EXERCISE_STOP); public final byte Code; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanExerciseData.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanExerciseData.java index a3a0000d60..4066f15371 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanExerciseData.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanExerciseData.java @@ -19,20 +19,31 @@ package nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman; import java.io.Serializable; -public class UltrahumanExerciseData implements Serializable { +import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample; + +/** + * Helper for DeviceService#ACTION_REALTIME_SAMPLES and UltrahumanConstants.ACTION_EXERCISE_UPDATE intents. + */ +public class UltrahumanExerciseData implements ActivitySample, Serializable{ public int BatteryLevel; public int Exercise; + + public int Timestamp; + public int MeasurementType; public int HR; public int HRV; public float Temperature; - public int Timestamp; + + public String Mystery; public UltrahumanExerciseData() { BatteryLevel = -1; Exercise = -1; HR = -1; HRV = -1; - Temperature = -1; + Temperature = -1.0f; Timestamp = -1; } @@ -41,4 +52,64 @@ public class UltrahumanExerciseData implements Serializable { BatteryLevel = batteryLevel; Exercise = exercise; } + + public UltrahumanExerciseData(int batteryLevel, int exercise, byte type) { + this(batteryLevel,exercise); + MeasurementType = type; + } + + @Override + public SampleProvider getProvider() { + return null; + } + + @Override + public int getRawKind() { + return 0; + } + + @Override + public ActivityKind getKind() { + return ActivityKind.UNKNOWN; + } + + @Override + public int getRawIntensity() { + return 0; + } + + @Override + public float getIntensity() { + return 0.0f; + } + + @Override + public int getSteps() { + return 0; + } + + @Override + public int getDistanceCm() { + return 0; + } + + @Override + public int getActiveCalories() { + return 0; + } + + @Override + public int getHeartRate() { + return HR; + } + + @Override + public void setHeartRate(int value) { + HR = value; + } + + @Override + public int getTimestamp() { + return Timestamp; + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/samples/UltrahumanActivitySampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/samples/UltrahumanActivitySampleProvider.java index 7f7b8e3113..481b1af33a 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/samples/UltrahumanActivitySampleProvider.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/samples/UltrahumanActivitySampleProvider.java @@ -35,18 +35,15 @@ public class UltrahumanActivitySampleProvider extends AbstractSampleProvider= 100) ? ActivityKind.NOT_WORN : ActivityKind.UNKNOWN; - } + return switch (rawType) { + case 5 -> ActivityKind.BREATHWORK; + case 6 -> ActivityKind.EXERCISE; + default -> (rawType >= 100) ? ActivityKind.NOT_WORN : ActivityKind.UNKNOWN; + }; } public static float normalizeIntensityStatic(int rawIntensity) { - return rawIntensity / 150f; + return rawIntensity / 150.0f; } @NonNull @@ -80,18 +77,13 @@ public class UltrahumanActivitySampleProvider extends AbstractSampleProvider 1; + case BREATHWORK -> 5; + case EXERCISE -> 6; + case NOT_WORN -> 100; + default -> 0; + }; } @Override diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/ultrahuman/UltrahumanCharacteristic.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/ultrahuman/UltrahumanCharacteristic.java index 158883ed82..fd6bf327ea 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/ultrahuman/UltrahumanCharacteristic.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/ultrahuman/UltrahumanCharacteristic.java @@ -22,11 +22,11 @@ import java.util.UUID; import nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.UltrahumanConstants; public enum UltrahumanCharacteristic { - COMMAND(UltrahumanConstants.UUID_CHARACTERISTIC_COMMAND), - RESPONSE(UltrahumanConstants.UUID_CHARACTERISTIC_RESPONSE), - STATE(UltrahumanConstants.UUID_CHARACTERISTIC_STATE), - DATA(UltrahumanConstants.UUID_CHARACTERISTIC_DATA), - TODO(UltrahumanConstants.UUID_CHARACTERISTIC_TODO); + COMMAND(UltrahumanConstants.UUID_COMMAND), + RESPONSE(UltrahumanConstants.UUID_RESPONSE), + STATE(UltrahumanConstants.UUID_STATE), + DATA(UltrahumanConstants.UUID_DATA), + TODO(UltrahumanConstants.UUID_TODO); public final UUID uuid; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/ultrahuman/UltrahumanDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/ultrahuman/UltrahumanDeviceSupport.java index d9920283cc..1fb7c2f041 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/ultrahuman/UltrahumanDeviceSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/ultrahuman/UltrahumanDeviceSupport.java @@ -21,19 +21,29 @@ import static nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.Ultrahuman import static nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.UltrahumanConstants.OPERATION_BREATHING_START; import static nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.UltrahumanConstants.OPERATION_BREATHING_STOP; import static nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.UltrahumanConstants.OPERATION_CHECK_DATA; -import static nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.UltrahumanConstants.OPERATION_DISABLE_POWERSAVE; -import static nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.UltrahumanConstants.OPERATION_ENABLE_POWERSAVE; +import static nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.UltrahumanConstants.OPERATION_DISABLE_SPO2; +import static nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.UltrahumanConstants.OPERATION_ENABLE_SPO2; +import static nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.UltrahumanConstants.OPERATION_EXERCISE_START; +import static nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.UltrahumanConstants.OPERATION_EXERCISE_STOP; import static nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.UltrahumanConstants.OPERATION_GET_FIRST_RECORDING_NR; import static nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.UltrahumanConstants.OPERATION_GET_LAST_RECORDING_NR; import static nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.UltrahumanConstants.OPERATION_GET_RECORDINGS; import static nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.UltrahumanConstants.OPERATION_RESET; import static nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.UltrahumanConstants.OPERATION_SETTIME; +import static nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.UltrahumanConstants.UUID_COMMAND; +import static nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.UltrahumanConstants.UUID_DATA; +import static nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.UltrahumanConstants.UUID_RESPONSE; +import static nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.UltrahumanConstants.UUID_STATE; +import static nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.UltrahumanConstants.UUID_TODO; +import static nodomain.freeyourgadget.gadgetbridge.impl.GBDevice.State.INITIALIZED; +import static nodomain.freeyourgadget.gadgetbridge.impl.GBDevice.State.INITIALIZING; import static nodomain.freeyourgadget.gadgetbridge.service.devices.ultrahuman.UltrahumanCharacteristic.COMMAND; import static nodomain.freeyourgadget.gadgetbridge.service.devices.ultrahuman.UltrahumanCharacteristic.DATA; import static nodomain.freeyourgadget.gadgetbridge.service.devices.ultrahuman.UltrahumanCharacteristic.RESPONSE; import static nodomain.freeyourgadget.gadgetbridge.service.devices.ultrahuman.UltrahumanCharacteristic.STATE; import static nodomain.freeyourgadget.gadgetbridge.service.devices.ultrahuman.UltrahumanCharacteristic.TODO; +import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothGatt; import android.bluetooth.BluetoothGattCharacteristic; import android.content.BroadcastReceiver; @@ -42,12 +52,16 @@ import android.content.Intent; import android.content.IntentFilter; import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; import androidx.core.content.ContextCompat; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import org.jetbrains.annotations.NonNls; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.Calendar; +import java.io.IOException; import java.util.UUID; import nodomain.freeyourgadget.gadgetbridge.BuildConfig; @@ -78,43 +92,42 @@ import nodomain.freeyourgadget.gadgetbridge.entities.UltrahumanActivitySample; import nodomain.freeyourgadget.gadgetbridge.entities.UltrahumanDeviceStateSample; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.model.BatteryState; +import nodomain.freeyourgadget.gadgetbridge.model.DeviceService; import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLESingleDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; +import nodomain.freeyourgadget.gadgetbridge.service.btle.BleNamesResolver; import nodomain.freeyourgadget.gadgetbridge.service.btle.GattService; import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; -import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.ReadAction; -import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.WriteAction; 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.service.serial.GBDeviceProtocol; -import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils; import nodomain.freeyourgadget.gadgetbridge.util.GB; -import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; +import nodomain.freeyourgadget.gadgetbridge.util.notifications.GBProgressNotification; public class UltrahumanDeviceSupport extends AbstractBTLESingleDeviceSupport { - private static final Logger LOG = LoggerFactory.getLogger(UltrahumanDeviceSupport.class); - private final UltrahumanBroadcastReceiver CommandReceiver; - private int FetchTo; - private int FetchFrom; - private int FetchCurrent; + private static final String LOG_READ_FAILED = "UH>>GB failed read {} {}"; + private static final String LOG_UNDECODED = "UH>>GB undecoded {} {}"; + private static final String LOG_UNHANDLED = "UH>>GB unhandled {} {}"; - private int LatestBatteryLevel = -1; - private int LatestExercise = -1; + private static final Logger LOG = LoggerFactory.getLogger(UltrahumanDeviceSupport.class); + + private UltrahumanReceiver CommandReceiver; + private GBProgressNotification ProgressNotification; + + private int FetchCurrent; + private int FetchFrom; + private int FetchTo; + + private int LatestBatteryLevel; + private BatteryState LatestBatteryState; + private int LatestExercise; + + private String FirmwareVersion = "N/A"; + private String FirmwareVersion2 = "N/A"; public UltrahumanDeviceSupport() { super(LOG); - - addSupportedService(UltrahumanConstants.UUID_SERVICE_REQUEST); - addSupportedService(UltrahumanConstants.UUID_SERVICE_STATE); - addSupportedService(UltrahumanConstants.UUID_SERVICE_DATA); - addSupportedService(UltrahumanConstants.UUID_SERVICE_TODO); - addSupportedService(GattService.UUID_SERVICE_DEVICE_INFORMATION); - - CommandReceiver = new UltrahumanBroadcastReceiver(); - - DeviceInfoProfile deviceProfile = new DeviceInfoProfile<>(this); - deviceProfile.addListener(CommandReceiver); - addSupportedProfile(deviceProfile); } @Override @@ -129,23 +142,185 @@ public class UltrahumanDeviceSupport extends AbstractBTLESingleDeviceSupport { } } + @Override + public boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, byte[] value) { + if (super.onCharacteristicChanged(gatt, characteristic, value)) { + return true; + } + + final UUID uuid = characteristic.getUuid(); + + if (STATE.uuid.equals(uuid)) { + return decodeSTATE(value); + } else if (RESPONSE.uuid.equals(uuid)) { + return decodeRESPONSE(value); + } else if (DATA.uuid.equals(uuid)) { + return decodeDATA(value); + } else if (TODO.uuid.equals(uuid)) { + return decodeTODO(value); + } + + LOG.info("UH>>GB unhandled onCharacteristicChanged {} {}", uuid, GB.hexdump(value)); + return false; + } + + @Override + public boolean onCharacteristicRead(final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic, byte[] value, final int status) { + if (super.onCharacteristicRead(gatt, characteristic, value, status)) { + return true; + } + + final UUID uuid = characteristic.getUuid(); + + if (STATE.uuid.equals(uuid)) { + if (status == BluetoothGatt.GATT_SUCCESS) { + return decodeSTATE(value); + } + LOG.warn(LOG_READ_FAILED, STATE, status); + return true; + } else if (DATA.uuid.equals(characteristic.getUuid())) { + if (status == BluetoothGatt.GATT_SUCCESS) { + return decodeDATA(value); + } + LOG.warn(LOG_READ_FAILED, DATA, status); + return true; + } else if (TODO.uuid.equals(characteristic.getUuid())) { + if (status == BluetoothGatt.GATT_SUCCESS) { + return decodeTODO(value); + } + LOG.warn(LOG_READ_FAILED, TODO, status); + return true; + } + + LOG.warn("UH>>GB unhandled onCharacteristicRead {} {}", + BleNamesResolver.resolveCharacteristicName(characteristic.getUuid().toString()), + GB.hexdump(value)); + return false; + } + + @Override + public void onEnableRealtimeHeartRateMeasurement(boolean enable) { + if (!enable || LatestExercise != OPERATION_EXERCISE_START) { + sendCommand("onEnableRealtimeHeartRateMeasurement", enable ? OPERATION_EXERCISE_START : OPERATION_EXERCISE_STOP); + } + } + + @Override + public void onFetchRecordedData(int dataTypes) { + if (getDevice().isBusy()) { + return; + } + + TransactionBuilder builder = createTransactionBuilder("onFetchRecordedData"); + builder.setBusyTask(R.string.busy_task_fetch_activity_data); + + // fetch deltas while the device is connected + FetchFrom = (FetchCurrent > 0) ? FetchCurrent : -1; + FetchTo = -1; + FetchCurrent = -1; + + if (FetchFrom <= 0) { + builder.write(UUID_COMMAND, OPERATION_GET_FIRST_RECORDING_NR); + } + builder.write(UUID_COMMAND, OPERATION_GET_LAST_RECORDING_NR); + + enqueue(builder); + } + + protected BluetoothGattCharacteristic getCharacteristic(UltrahumanCharacteristic characteristic) { + return getCharacteristic(characteristic.uuid); + } + + @Override + public void onReset(int flags) { + if ((flags & GBDeviceProtocol.RESET_FLAGS_FACTORY_RESET) == GBDeviceProtocol.RESET_FLAGS_FACTORY_RESET) { + TransactionBuilder builder = createTransactionBuilder("onReset"); + builder.write(UUID_COMMAND, OPERATION_RESET); + builder.run(this::disconnect); + try { + builder.queueConnected(); + } catch (IOException e) { + LOG.error("onReset failed {}", e.getMessage(), e); + } + } + } + + @Override + public void onSendConfiguration(@NonNls String config) { + super.onSendConfiguration(config); + TransactionBuilder builder = createTransactionBuilder("onSendConfiguration"); + if (DeviceSettingsPreferenceConst.PREF_TIME_SYNC.equals(config)) { + onSetTime(builder); + } else if (DeviceSettingsPreferenceConst.PREF_SPO2_ALL_DAY_MONITORING.equals(config)) { + onSetSpo2(builder); + } else { + return; + } + enqueue(builder); + } + + @Override + public void onSetTime() { + TransactionBuilder builder = createTransactionBuilder("onSetTime"); + onSetTime(builder); + enqueue(builder); + } + + private void onSetTime(TransactionBuilder builder) { + boolean timeSync = getDevicePrefs().getBoolean(DeviceSettingsPreferenceConst.PREF_TIME_SYNC, true); + if (timeSync) { + builder.add(new UltrahumanWriteTime(getCharacteristic(COMMAND))); + } + } + + private void onSetSpo2(TransactionBuilder builder) { + boolean spo2 = getDevicePrefs().getBoolean(DeviceSettingsPreferenceConst.PREF_SPO2_ALL_DAY_MONITORING, false); + builder.write(UUID_COMMAND, spo2 ? OPERATION_ENABLE_SPO2 : OPERATION_DISABLE_SPO2); + } + + @Override + public void setContext(final GBDevice gbDevice, final BluetoothAdapter btAdapter, final Context context) { + // a completed "this" is required so do the initialization here and not in the constructor + if (CommandReceiver == null) { + addSupportedService(UltrahumanConstants.UUID_SERVICE_REQUEST); + addSupportedService(UltrahumanConstants.UUID_SERVICE_STATE); + addSupportedService(UltrahumanConstants.UUID_SERVICE_DATA); + addSupportedService(UltrahumanConstants.UUID_SERVICE_TODO); + addSupportedService(GattService.UUID_SERVICE_DEVICE_INFORMATION); + + CommandReceiver = new UltrahumanReceiver(); + + DeviceInfoProfile deviceProfile = new DeviceInfoProfile<>(this); + deviceProfile.addListener(CommandReceiver); + addSupportedProfile(deviceProfile); + } + + super.setContext(gbDevice, btAdapter, context); + ProgressNotification = new GBProgressNotification(getContext(), GB.NOTIFICATION_CHANNEL_ID_TRANSFER); + } + + @Override + public boolean useAutoConnect() { + return false; + } + @Override protected TransactionBuilder initializeDevice(TransactionBuilder builder) { - // reset to avoid funny states for re-connect + // reset here to avoid funny states for re-connect FetchTo = -1; FetchFrom = -1; FetchCurrent = -1; LatestBatteryLevel = -1; + LatestBatteryState = BatteryState.NO_BATTERY; LatestExercise = -1; // required for DB - if (getDevice().getFirmwareVersion() == null) { - getDevice().setFirmwareVersion("N/A"); - getDevice().setFirmwareVersion2("N/A"); + GBDevice device = getDevice(); + if (device.getFirmwareVersion() == null) { + device.setFirmwareVersion(FirmwareVersion); + device.setFirmwareVersion2(FirmwareVersion2); } - builder.setDeviceState(GBDevice.State.INITIALIZING); - if (!CommandReceiver.Registered) { IntentFilter exported = new IntentFilter(); exported.addAction(UltrahumanConstants.ACTION_AIRPLANE_MODE); @@ -155,291 +330,243 @@ public class UltrahumanDeviceSupport extends AbstractBTLESingleDeviceSupport { CommandReceiver.Registered = true; } + builder.setDeviceState(INITIALIZING); + // trying to read non-existing characteristics sometimes causes odd BLE failures // so avoid DeviceInfoProfile.requestDeviceInfo - builder.read(getCharacteristic(DeviceInfoProfile.UUID_CHARACTERISTIC_HARDWARE_REVISION_STRING)); - builder.read(getCharacteristic(DeviceInfoProfile.UUID_CHARACTERISTIC_FIRMWARE_REVISION_STRING)); - builder.read(getCharacteristic(DeviceInfoProfile.UUID_CHARACTERISTIC_SERIAL_NUMBER_STRING)); + builder.read(DeviceInfoProfile.UUID_CHARACTERISTIC_HARDWARE_REVISION_STRING); + builder.read(DeviceInfoProfile.UUID_CHARACTERISTIC_FIRMWARE_REVISION_STRING); + builder.read(DeviceInfoProfile.UUID_CHARACTERISTIC_SERIAL_NUMBER_STRING); - // sometimes the device is quite quick and other times it takes a while after - // BLE connectivity has been established before the services work reliably - builder.wait(48 * 2); //BluetoothGatt.onConnectionUpdated typically reports interval=48 + builder.read(UUID_STATE); - builder.add(new UHRead(STATE)); + builder.notify(UUID_RESPONSE, true); + builder.notify(UUID_STATE, true); + builder.notify(UUID_DATA, true); + builder.notify(UUID_TODO, true); - builder.notify(getCharacteristic(RESPONSE.uuid), true); - builder.notify(getCharacteristic(STATE.uuid), true); - builder.notify(getCharacteristic(DATA.uuid), true); - builder.notify(getCharacteristic(TODO.uuid), true); + builder.write(UUID_COMMAND, OPERATION_CHECK_DATA); - builder.add(new UHWrite(COMMAND, OPERATION_CHECK_DATA)); + onSetTime(builder); + onSetSpo2(builder); - boolean timeSync = getDevicePrefs().getBoolean(DeviceSettingsPreferenceConst.PREF_TIME_SYNC, true); - if (timeSync) { - builder.add(new UHSetTime(COMMAND)); - } - - boolean spo2 = getDevicePrefs().getBoolean(DeviceSettingsPreferenceConst.PREF_SPO2_ALL_DAY_MONITORING, false); - builder.add(new UHWrite(COMMAND, spo2 ? OPERATION_DISABLE_POWERSAVE : OPERATION_ENABLE_POWERSAVE)); - - builder.setDeviceState(GBDevice.State.INITIALIZED); + builder.setDeviceState(INITIALIZED); return builder; } - @Override - public void onFetchRecordedData(int dataTypes) { - String title = getContext().getString(R.string.busy_task_fetch_activity_data); - GB.updateTransferNotification(title, "", true, 0, getContext()); - - getDevice().setBusyTask(R.string.busy_task_fetch_activity_data, getContext()); - getDevice().sendDeviceUpdateIntent(getContext()); - - TransactionBuilder builder = createTransactionBuilder("onFetchRecordedData"); - buildFetchRecordedData(builder); - enqueue(builder); - } - - private void buildFetchRecordedData(TransactionBuilder builder) { - // fetch deltas while the device is connected - FetchFrom = (FetchCurrent > 0) ? FetchCurrent : -1; - FetchTo = -1; - FetchCurrent = -1; - - if (FetchFrom <= 0) { - builder.add(new UHWrite(COMMAND, OPERATION_GET_FIRST_RECORDING_NR)); - } - builder.add(new UHWrite(COMMAND, OPERATION_GET_LAST_RECORDING_NR)); - } - - @Override - public boolean onCharacteristicRead(final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic, - final byte[] raw, final int status) { - if (super.onCharacteristicRead(gatt, characteristic, raw, status)) { + private boolean decodeDATA(byte[] raw) { + if (raw.length == 27 && raw[0] == OPERATION_BREATHING_START) { + decodeDATA_Breathing(raw); + return true; + } else if (raw.length == 11 && raw[0] == OPERATION_EXERCISE_START) { + decodeDATA_Exercise(raw); return true; } - - if (STATE.uuid.equals(characteristic.getUuid())) { - LOG.debug("UH>>GB read {} {} {}", STATE, status, StringUtils.bytesToHex(raw)); - if (status == BluetoothGatt.GATT_SUCCESS) { - return decodeDeviceState(raw); - } - } - + LOG.warn(LOG_UNHANDLED, DATA, GB.hexdump(raw)); return false; } - @Override - public boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, byte[] raw) { - if (super.onCharacteristicChanged(gatt, characteristic, raw)) { - return true; - } - - UUID characteristicUUID = characteristic.getUuid(); - - if (STATE.uuid.equals(characteristicUUID)) { - LOG.debug("UH>>GB changed STATE {}", StringUtils.bytesToHex(raw)); - return decodeDeviceState(raw); - } else if (RESPONSE.uuid.equals(characteristicUUID)) { - LOG.debug("UH>>GB changed RESPONSE {}", StringUtils.bytesToHex(raw)); - if (raw.length < 3) { - LOG.error("UH>>GB !! RESPONSE too short: {}", StringUtils.bytesToHex(raw)); - return false; - } - - final byte op = raw[0]; - final byte success = raw[1]; - final byte result = raw[2]; - // ignore checksums for now - algorithm is unknown - //byte chk1 = raw[raw.length-1]; - //byte chk2 = raw[raw.length-2]; - - Context context = getContext(); - - switch (op) { - case OPERATION_GET_RECORDINGS: - return decodeRecordings(raw); - case OPERATION_CHECK_DATA: - case OPERATION_BREATHING_START: - case OPERATION_BREATHING_STOP: - if (success == 0x00 && result == 0x01 && raw.length >= 5) { - LatestExercise = raw[3]; - publishExerciseData(); - return true; - } - break; - case OPERATION_ACTIVATE_AIRPLANE_MODE: - switch (result) { - case 0x01: - GB.toast(context, context.getString(R.string.ultrahuman_airplane_mode_activated), Toast.LENGTH_LONG, GB.INFO); - return true; - case 0x02: - GB.toast(context, context.getString(R.string.ultrahuman_airplane_mode_on_charger), Toast.LENGTH_LONG, GB.ERROR); - return true; - case 0x03: - GB.toast(context, context.getString(R.string.ultrahuman_airplane_mode_too_full), Toast.LENGTH_LONG, GB.ERROR); - return true; - } - - String airplaneMessage = getContext().getString(R.string.ultrahuman_airplane_mode_unknown, result); - GB.toast(context, airplaneMessage, Toast.LENGTH_LONG, GB.ERROR); - return false; - - case OPERATION_GET_FIRST_RECORDING_NR: - if (success == 0x00 && result == 0x01) { - FetchFrom = BLETypeConversions.toUint16(raw, 3); - if (FetchTo != -1) { - fetchRecordingActually(); - } - return true; - } - fetchRecordedDataFinished(); - break; - - case OPERATION_GET_LAST_RECORDING_NR: - if (success == 0x00 && result == 0x01) { - FetchTo = BLETypeConversions.toUint16(raw, 3); - if (FetchFrom != -1) { - fetchRecordingActually(); - } - return true; - } - fetchRecordedDataFinished(); - break; - - case OPERATION_DISABLE_POWERSAVE: - case OPERATION_ENABLE_POWERSAVE: - case OPERATION_SETTIME: - if (success == 0x00 && result == 0x01) { - return true; - } - break; - - default: - LOG.warn("UH>>GB !! changed {} A {}", RESPONSE, StringUtils.bytesToHex(raw)); - String message = getContext().getString(R.string.ultrahuman_unhandled_operation_response, StringUtils.bytesToHex(raw)); - GB.toast(getContext(), message, Toast.LENGTH_LONG, GB.ERROR); - return false; - } - - LOG.warn("UH>>GB !! changed {} B {}", RESPONSE, StringUtils.bytesToHex(raw)); - String message = getContext().getString(R.string.ultrahuman_unhandled_error_response, op, success, result); - GB.toast(getContext(), message, Toast.LENGTH_LONG, GB.ERROR); - return false; - } else if (DATA.uuid.equals(characteristicUUID)) { - if (raw[0] == 0x72 && raw.length >= 27) { - LOG.debug("UH>>GB changed {} {}", DATA, StringUtils.bytesToHex(raw)); - decodeBreathing(raw); - return true; - } - LOG.warn("UH>>GB !! changed {} 1 {}", DATA, StringUtils.bytesToHex(raw)); - return true; - } else if (TODO.uuid.equals(characteristicUUID)) { - LOG.warn("UH>>GB !! changed {} {}", TODO, StringUtils.bytesToHex(raw)); - return true; - } - - LOG.warn("UH>>GB !! unhandled characteristic {} - {} ", characteristicUUID, StringUtils.bytesToHex(raw)); - return false; - } - - private void decodeBreathing(byte[] raw) { - char[] undecoded = StringUtils.bytesToHex(raw).toCharArray(); - - UltrahumanExerciseData data = new UltrahumanExerciseData(LatestBatteryLevel, LatestExercise); + private void decodeDATA_Breathing(byte[] raw) { + UltrahumanExerciseData data = new UltrahumanExerciseData(LatestBatteryLevel, LatestExercise, raw[0]); data.Timestamp = BLETypeConversions.toUint32(raw, 3); - undecoded[3 * 2] = undecoded[3 * 2 + 1] = '.'; - undecoded[4 * 2] = undecoded[4 * 2 + 1] = '.'; - undecoded[5 * 2] = undecoded[5 * 2 + 1] = '.'; - undecoded[6 * 2] = undecoded[6 * 2 + 1] = '.'; - data.HR = BLETypeConversions.toUnsigned(raw, 7); - undecoded[7 * 2] = undecoded[7 * 2 + 1] = '.'; - data.HRV = BLETypeConversions.toUnsigned(raw, 9); - undecoded[9 * 2] = undecoded[9 * 2 + 1] = '.'; - data.Temperature = Float.intBitsToFloat(BLETypeConversions.toUint32(raw, 11)); - undecoded[11 * 2] = undecoded[11 * 2 + 1] = '.'; - undecoded[12 * 2] = undecoded[12 * 2 + 1] = '.'; - undecoded[13 * 2] = undecoded[13 * 2 + 1] = '.'; - undecoded[14 * 2] = undecoded[14 * 2 + 1] = '.'; + data.Mystery = Integer.toString(BLETypeConversions.toUint16(raw, 23)); - // type - undecoded[0] = undecoded[1] = '.'; + if (BuildConfig.DEBUG) { + final char[] undecoded = GB.hexdump(raw).toCharArray(); - // payload length - undecoded[2] = undecoded[3] = '.'; - undecoded[4] = undecoded[5] = '.'; + // keep type ([0], [1]) for log message - // check sum - undecoded[undecoded.length - 4] = '.'; - undecoded[undecoded.length - 3] = '.'; - undecoded[undecoded.length - 2] = '.'; - undecoded[undecoded.length - 1] = '.'; + // payload length + undecoded[1 * 2] = undecoded[1 * 2 + 1] = '.'; + undecoded[2 * 2] = undecoded[2 * 2 + 2] = '.'; + + // time stamp + undecoded[3 * 2] = undecoded[3 * 2 + 1] = '.'; + undecoded[4 * 2] = undecoded[4 * 2 + 1] = '.'; + undecoded[5 * 2] = undecoded[5 * 2 + 1] = '.'; + undecoded[6 * 2] = undecoded[6 * 2 + 1] = '.'; + + // HR + undecoded[7 * 2] = undecoded[7 * 2 + 1] = '.'; + + // HRV + undecoded[9 * 2] = undecoded[9 * 2 + 1] = '.'; + + // temperature + undecoded[11 * 2] = undecoded[11 * 2 + 1] = '.'; + undecoded[12 * 2] = undecoded[12 * 2 + 1] = '.'; + undecoded[13 * 2] = undecoded[13 * 2 + 1] = '.'; + undecoded[14 * 2] = undecoded[14 * 2 + 1] = '.'; + + // check sum + undecoded[undecoded.length - 4] = '.'; + undecoded[undecoded.length - 3] = '.'; + undecoded[undecoded.length - 2] = '.'; + undecoded[undecoded.length - 1] = '.'; + + LOG.debug(LOG_UNDECODED, DATA, new String(undecoded)); + } - LOG.debug("UH>>GB decode DAT72 {}", new String(undecoded)); publishExerciseData(data); } - private void publishExerciseData() { - if (LatestExercise > -1 && LatestBatteryLevel > -1) { - UltrahumanExerciseData data = new UltrahumanExerciseData(LatestBatteryLevel, LatestExercise); - publishExerciseData(data); + private void decodeDATA_Exercise(byte[] raw) { + UltrahumanExerciseData data = new UltrahumanExerciseData(LatestBatteryLevel, LatestExercise, raw[0]); + + data.Timestamp = BLETypeConversions.toUint32(raw, 3); + data.HR = BLETypeConversions.toUnsigned(raw, 7); + data.Mystery = Integer.toString(BLETypeConversions.toUnsigned(raw, 8)); + // no other payload fields + + if (BuildConfig.DEBUG) { + final char[] undecoded = GB.hexdump(raw).toCharArray(); + + // keep type ([0], [1]) for log message + + // payload length + undecoded[2] = undecoded[3] = '.'; + undecoded[4] = undecoded[5] = '.'; + + // time stamp + undecoded[3 * 2] = undecoded[3 * 2 + 1] = '.'; + undecoded[4 * 2] = undecoded[4 * 2 + 1] = '.'; + undecoded[5 * 2] = undecoded[5 * 2 + 1] = '.'; + undecoded[6 * 2] = undecoded[6 * 2 + 1] = '.'; + + // HR + undecoded[7 * 2] = undecoded[7 * 2 + 1] = '.'; + + // check sum + undecoded[undecoded.length - 4] = '.'; + undecoded[undecoded.length - 3] = '.'; + undecoded[undecoded.length - 2] = '.'; + undecoded[undecoded.length - 1] = '.'; + + LOG.debug(LOG_UNDECODED, DATA, new String(undecoded)); } + + publishExerciseData(data); } - private void publishExerciseData(UltrahumanExerciseData data) { - final Intent intent = new Intent(UltrahumanConstants.ACTION_EXERCISE_UPDATE); - intent.setPackage(BuildConfig.APPLICATION_ID); - - String address = getDevice().getAddress(); - intent.putExtra(UltrahumanConstants.EXTRA_ADDRESS, address); - intent.putExtra(UltrahumanConstants.EXTRA_EXERCISE, data); - - final Context context = getContext(); - context.sendBroadcast(intent); - } - - @Override - public void onSendConfiguration(String config) { - if (DeviceSettingsPreferenceConst.PREF_TIME_SYNC.equals(config)) { - onSetTime(); - } else if (DeviceSettingsPreferenceConst.PREF_SPO2_ALL_DAY_MONITORING.equals(config)) { - boolean spo2 = getDevicePrefs().getBoolean(DeviceSettingsPreferenceConst.PREF_SPO2_ALL_DAY_MONITORING, false); - sendCommand("onSendConfiguration-SPO2", spo2 ? OPERATION_DISABLE_POWERSAVE : OPERATION_ENABLE_POWERSAVE); - } else { - super.onSendConfiguration(config); + private boolean decodeRESPONSE(final byte[] raw) { + if (raw.length < 3) { + LOG.error("UH>>GB {} too short: {}", RESPONSE, GB.hexdump(raw)); + return false; } - } - @Override - public boolean useAutoConnect() { + final byte op = raw[0]; + final byte success = raw[1]; + final byte result = raw[2]; + // ignore checksums for now - algorithm is unknown + //byte chk1 = raw[raw.length-1]; + //byte chk2 = raw[raw.length-2]; + + switch (op) { + case OPERATION_GET_RECORDINGS: + return decodeRESPONSE_RecordedData(raw); + + case OPERATION_BREATHING_START: + if (success == (byte) 0xEE && result == 0x01) { + LOG.info("UH>>GB {} breathing is already started", RESPONSE); + return true; + } + // fall through + case OPERATION_EXERCISE_START: + case OPERATION_CHECK_DATA: + case OPERATION_BREATHING_STOP: + if (success == 0x00 && result == 0x01 && raw.length == 6) { + LatestExercise = raw[3]; + publishExerciseData(); + return true; + } + break; + case OPERATION_EXERCISE_STOP: + if (success == (byte) 0xFF && result == 0x01) { + LOG.info("UH>>GB {} exercise is already stopped", RESPONSE); + return true; + } + if (success == 0x00 && result == 0x01 && raw.length == 6) { + LatestExercise = raw[3]; + publishExerciseData(); + return true; + } + break; + case OPERATION_ACTIVATE_AIRPLANE_MODE: + switch (result) { + case 0x01: + uiInfo(R.string.ultrahuman_airplane_mode_activated); + return true; + case 0x02: + uiError(getContext().getString(R.string.ultrahuman_airplane_mode_on_charger)); + return true; + case 0x03: + uiError(getContext().getString(R.string.ultrahuman_airplane_mode_too_full)); + return true; + } + + uiError(getContext().getString(R.string.ultrahuman_airplane_mode_unknown, result)); + return false; + + case OPERATION_GET_FIRST_RECORDING_NR: + if (success == 0x00 && result == 0x01) { + FetchFrom = BLETypeConversions.toUint16(raw, 3); + if (FetchTo != -1) { + fetchRecordedDataActually(); + } + return true; + } + fetchRecordedDataFinished(); + break; + + case OPERATION_GET_LAST_RECORDING_NR: + if (success == 0x00 && result == 0x01) { + FetchTo = BLETypeConversions.toUint16(raw, 3); + if (FetchFrom != -1) { + fetchRecordedDataActually(); + } + return true; + } + fetchRecordedDataFinished(); + break; + + case OPERATION_DISABLE_SPO2: + case OPERATION_ENABLE_SPO2: + case OPERATION_SETTIME: + if (success == 0x00 && result == 0x01) { + return true; + } + break; + + default: + LOG.warn(LOG_UNHANDLED, RESPONSE, GB.hexdump(raw)); + uiError(getContext().getString(R.string.ultrahuman_unhandled_operation_response, GB.hexdump(raw))); + return false; + } + + LOG.warn(LOG_UNHANDLED, RESPONSE, GB.hexdump(raw)); + uiError(getContext().getString(R.string.ultrahuman_unhandled_error_response, op, success, result)); return false; } - private void fetchRecordingActually() { - // ID overflow - if (FetchFrom > FetchTo) { - FetchFrom = 0; - } - sendCommand("fetchRecordingActually", OPERATION_GET_RECORDINGS, (byte) (FetchFrom & 0xFF), (byte) ((FetchFrom >> 8) & 0xFF)); - } - - private boolean decodeRecordings(byte[] raw) { + private boolean decodeRESPONSE_RecordedData(final byte[] raw) { if (raw[1] != 0) { if ((raw[1] & 0xFF) == 0xEE) { - LOG.warn("!! no historic data recorded"); + LOG.info("UH>>GB no historic data recorded"); } else { - String message = getContext().getString(R.string.ultrahuman_unhandled_error_response, raw[0], raw[1], raw[2]); - GB.toast(getContext(), message, Toast.LENGTH_LONG, GB.ERROR); + uiError(getContext().getString(R.string.ultrahuman_unhandled_error_response, raw[0], raw[1], raw[2])); } fetchRecordedDataFinished(); return raw.length == 5; } + final int entries = raw[2]; + boolean success = true; try (DBHandler db = GBApplication.acquireDB()) { GBDevice device = getDevice(); @@ -448,22 +575,24 @@ public class UltrahumanDeviceSupport extends AbstractBTLESingleDeviceSupport { Long userId = DBHelper.getUser(session).getId(); Long deviceId = DBHelper.getDevice(device, session).getId(); - for (int record = 0; record < raw[2]; record++) { - success &= decodeRecording(raw, 3 + record * 32, device, session, deviceId, userId, record == 0); + for (int entry = 0; entry < entries; entry++) { + //noinspection NonShortCircuitBooleanExpression + success &= decodeRESPONSE_RecordedDataEntry(raw, 3 + entry * 32, device, session, deviceId, userId, entry == 0); } } catch (Exception e) { - LOG.error("Error acquiring database for recording historic sample", e); + LOG.error("UH>>GB error acquiring database for decodeRESPONSE_RecordedData {}", e.getMessage(), e); } - if (FetchCurrent >= FetchTo || raw[2] < 7) { + if (FetchCurrent >= FetchTo || entries < 7) { fetchRecordedDataFinished(); } + return success; } - private boolean decodeRecording(byte[] raw, int start, GBDevice device, DaoSession session, long deviceId, long userId, boolean updateProgress) { + private boolean decodeRESPONSE_RecordedDataEntry(byte[] raw, int start, GBDevice device, DaoSession session, long deviceId, long userId, boolean updateProgress) { if (raw.length < start + 32) { - LOG.error("length of history record is only from {} to {} instead of expected {}: {}", start, raw.length, start + 32, StringUtils.bytesToHex(raw)); + LOG.error("UH>>GB length of history record is only from {} to {} instead of expected {}: {}", start, raw.length, start + 32, GB.hexdump(raw)); return false; } @@ -485,14 +614,7 @@ public class UltrahumanDeviceSupport extends AbstractBTLESingleDeviceSupport { int index = BLETypeConversions.toUint16(raw, start + 30); if (updateProgress) { - int target = (FetchTo - FetchFrom); - if (target != 0) { - int progress = ((index - FetchFrom) * 100) / target; - if (progress > 99) { - progress = 99; - } - GB.updateTransferNotification(null, Integer.toString(index), true, progress, getContext()); - } + ProgressNotification.setTotalProgress(index - FetchFrom); } FetchCurrent = Integer.max(FetchCurrent, index); @@ -518,7 +640,7 @@ public class UltrahumanDeviceSupport extends AbstractBTLESingleDeviceSupport { } if (temperatureMax != 0.0f || temperatureMin != 0.0f) { - float temperature = (temperatureMax + temperatureMin) / 2f; + float temperature = (temperatureMax + temperatureMin) / 2.0f; GenericTemperatureSampleProvider provider = new GenericTemperatureSampleProvider(device, session); GenericTemperatureSample sample = new GenericTemperatureSample(timestampTemp * 1000L, deviceId, userId, temperature, 0); provider.addSample(sample); @@ -540,7 +662,7 @@ public class UltrahumanDeviceSupport extends AbstractBTLESingleDeviceSupport { return true; } - private boolean decodeDeviceState(byte[] raw) { + private boolean decodeSTATE(byte[] raw) { boolean success = false; BatteryState batteryState = BatteryState.UNKNOWN; @@ -550,22 +672,26 @@ public class UltrahumanDeviceSupport extends AbstractBTLESingleDeviceSupport { try (DBHandler db = GBApplication.acquireDB()) { if (raw.length != 7) { - LOG.warn("!! received Device State with unexpected length {}: {}", raw.length, StringUtils.bytesToHex(raw)); + LOG.warn("UH>>GB received {} with unexpected length {}: {}", STATE, raw.length, GB.hexdump(raw)); } else { - char[] undecoded = StringUtils.bytesToHex(raw).toCharArray(); - batteryLevel = 0xFF & raw[0]; - undecoded[0] = undecoded[1] = '.'; - - // decoding of 1..4 is unknown - deviceState = 0xFF & raw[5]; - undecoded[5 * 2] = undecoded[5 * 2 + 1] = '.'; - deviceTemperature = 0xFF & raw[6]; - undecoded[6 * 2] = undecoded[6 * 2 + 1] = '.'; - LOG.debug("UH>>GB decode STATE {}", new String(undecoded)); + if (BuildConfig.DEBUG) { + char[] undecoded = GB.hexdump(raw).toCharArray(); + + // battery + undecoded[0] = undecoded[1] = '.'; + + // device state + undecoded[5 * 2] = undecoded[5 * 2 + 1] = '.'; + + // device temperature + undecoded[6 * 2] = undecoded[6 * 2 + 1] = '.'; + + LOG.debug(LOG_UNDECODED, STATE, new String(undecoded)); + } switch (deviceState) { case 0x00: @@ -575,8 +701,10 @@ public class UltrahumanDeviceSupport extends AbstractBTLESingleDeviceSupport { batteryState = (batteryLevel > 99) ? BatteryState.BATTERY_CHARGING_FULL : BatteryState.BATTERY_CHARGING; break; default: - LOG.warn("!! DeviceState contains unhandled device state {}: {}", raw[5], StringUtils.bytesToHex(raw)); + LOG.warn("UH>>GB {} contains unhandled device state {}: {}", STATE, raw[5], GB.hexdump(raw)); } + + LOG.debug("device: state={} charge={} temperature={}", batteryState, batteryLevel, deviceTemperature); } GBDevice device = getDevice(); @@ -592,97 +720,123 @@ public class UltrahumanDeviceSupport extends AbstractBTLESingleDeviceSupport { sampleProvider.addSample(sample); success = true; } catch (Exception e) { - LOG.error("Error acquiring database for recording device state sample", e); - LOG.warn("device state sample: {}", StringUtils.bytesToHex(raw)); + LOG.error("Error acquiring database for recording device state sample {}", e.getMessage(), e); + LOG.warn("device state sample: {}", GB.hexdump(raw)); } GBDeviceEventBatteryInfo batteryEvent = new GBDeviceEventBatteryInfo(); batteryEvent.level = (batteryLevel == null) ? -1 : batteryLevel; batteryEvent.state = batteryState; - evaluateGBDeviceEvent(batteryEvent); + + boolean batteryChanged = false; if (batteryEvent.level != LatestBatteryLevel) { LatestBatteryLevel = batteryEvent.level; + batteryChanged = true; + } + + if (batteryEvent.state != LatestBatteryState) { + LatestBatteryState = batteryState; + batteryChanged = true; + } + + if (batteryChanged) { + // avoid spamming if no relevant details were changed + evaluateGBDeviceEvent(batteryEvent); publishExerciseData(); } return success; } - private void handleDeviceInfo(nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.deviceinfo.DeviceInfo info) { - GBDeviceEventVersionInfo versionCmd = new GBDeviceEventVersionInfo(); - versionCmd.fwVersion = info.getFirmwareRevision(); - versionCmd.fwVersion2 = info.getSerialNumber(); - versionCmd.hwVersion = info.getHardwareRevision(); - handleGBDeviceEvent(versionCmd); + private boolean decodeTODO(byte[] raw) { + LOG.warn(LOG_UNHANDLED, TODO, GB.hexdump(raw)); + return false; } - private void fetchRecordedDataFinished() { - GB.updateTransferNotification(null, "", false, 100, getContext()); - getDevice().unsetBusyTask(); - getDevice().sendDeviceUpdateIntent(getContext()); - GB.signalActivityDataFinish(getDevice()); - } - - @Override - public void onReset(int flags) { - if ((flags & GBDeviceProtocol.RESET_FLAGS_FACTORY_RESET) == GBDeviceProtocol.RESET_FLAGS_FACTORY_RESET) { - sendCommand("onReset", OPERATION_RESET); + private void fetchRecordedDataActually() { + // ID overflow + if (FetchFrom > FetchTo) { + FetchFrom = 0; } + ProgressNotification.start(R.string.busy_task_fetch_activity_data, 0, FetchTo - FetchFrom); + sendCommand("fetchRecordedDataActually", OPERATION_GET_RECORDINGS, (byte) (FetchFrom & 0xFF), (byte) ((FetchFrom >> 8) & 0xFF)); } - @Override - public void onSetTime() { - boolean timeSync = getDevicePrefs().getBoolean(DeviceSettingsPreferenceConst.PREF_TIME_SYNC, true); - if (timeSync) { - TransactionBuilder builder = createTransactionBuilder("onSetTime"); - builder.add(new UHSetTime(COMMAND)); - - enqueue(builder); - } - } - - private void sendCommand(String taskName, byte... contents) { - TransactionBuilder builder = createTransactionBuilder(taskName); - builder.add(new UHWrite(COMMAND, contents)); - - enqueue(builder); - } - - private void changeExercise(byte exercise) { - TransactionBuilder builder = createTransactionBuilder("changeExercise"); - if (exercise != UltrahumanExercise.CHECK.Code) { - builder.add(new UHWrite(COMMAND, exercise)); - } - builder.add(new UHWrite(COMMAND, OPERATION_CHECK_DATA)); - - enqueue(builder); - } - - private void enqueue(final TransactionBuilder builder) { + void enqueue(@NonNull final TransactionBuilder builder) { if (isConnected()) { builder.queue(); } else { - GB.toast(getContext(), R.string.devicestatus_disconnected, Toast.LENGTH_LONG, GB.ERROR); + uiError(getContext().getString(R.string.devicestatus_disconnected)); } } - private BluetoothGattCharacteristic resolve(UltrahumanCharacteristic chara) { - switch (chara) { - case DATA: - return getCharacteristic(UltrahumanConstants.UUID_CHARACTERISTIC_DATA); - case STATE: - return getCharacteristic(UltrahumanConstants.UUID_CHARACTERISTIC_STATE); - case COMMAND: - return getCharacteristic(UltrahumanConstants.UUID_CHARACTERISTIC_COMMAND); - case RESPONSE: - return getCharacteristic(UltrahumanConstants.UUID_CHARACTERISTIC_RESPONSE); + private void fetchRecordedDataFinished() { + ProgressNotification.finish(); + GBDevice device = getDevice(); + if (device.isBusy()) { + device.unsetBusyTask(); + device.sendDeviceUpdateIntent(getContext()); } - LOG.error("resolve {} is unknown", chara); - return null; + GB.signalActivityDataFinish(device); } - private class UltrahumanBroadcastReceiver extends BroadcastReceiver implements IntentListener { + void handleDeviceInfo(@NonNull DeviceInfo deviceInfo) { + GBDeviceEventVersionInfo info = new GBDeviceEventVersionInfo(); + info.fwVersion = deviceInfo.getFirmwareRevision(); + info.fwVersion2 = deviceInfo.getSerialNumber(); + info.hwVersion = deviceInfo.getHardwareRevision(); + if (info.fwVersion != null && info.fwVersion2 != null && info.hwVersion != null) { + FirmwareVersion = info.fwVersion; + FirmwareVersion2 = info.fwVersion2; + handleGBDeviceEvent(info); + } + } + + private void publishExerciseData() { + if (LatestExercise > -1 && LatestBatteryLevel > -1) { + UltrahumanExerciseData data = new UltrahumanExerciseData(LatestBatteryLevel, LatestExercise); + publishExerciseData(data); + } + } + + private void publishExerciseData(@NonNull final UltrahumanExerciseData data) { + String action; + if (data.HR > -1 && data.Timestamp != -1) { + action = DeviceService.ACTION_REALTIME_SAMPLES; + } else { + action = UltrahumanConstants.ACTION_EXERCISE_UPDATE; + } + + LOG.debug("publishExerciseData BatteryLevel:{} Exercise:{} Timestamp:{} HR:{} HRV:{} Temperature:{}", + data.BatteryLevel, data.Exercise, + data.Timestamp, data.HR, data.HRV, data.Temperature); + + final Intent intent = new Intent(action); + intent.setPackage(BuildConfig.APPLICATION_ID); + + intent.putExtra(GBDevice.EXTRA_DEVICE, getDevice()); + intent.putExtra(DeviceService.EXTRA_REALTIME_SAMPLE, data); + + LocalBroadcastManager.getInstance(getContext()).sendBroadcast(intent); + } + + + void sendCommand(String taskName, byte... contents) { + TransactionBuilder builder = createTransactionBuilder(taskName); + builder.write(UUID_COMMAND, contents); + enqueue(builder); + } + + private void uiError(String message) { + GB.toast(message, Toast.LENGTH_LONG, GB.ERROR); + } + + private void uiInfo(@StringRes int stringRes) { + GB.toast(getContext(), stringRes, Toast.LENGTH_LONG, GB.INFO); + } + + final class UltrahumanReceiver extends BroadcastReceiver implements IntentListener { boolean Registered; @Override @@ -692,15 +846,16 @@ public class UltrahumanDeviceSupport extends AbstractBTLESingleDeviceSupport { @Override public void notify(Intent intent) { - final String address = intent.getStringExtra(UltrahumanConstants.EXTRA_ADDRESS); + final @NonNls String address = intent.getStringExtra(UltrahumanConstants.EXTRA_ADDRESS); if (address != null && !address.isEmpty() && !address.equalsIgnoreCase(getDevice().getAddress())) { // this intent is for another device return; } - final String action = intent.getAction(); + final @NonNls String action = intent.getAction(); if (action == null) { + // invalid intent return; } @@ -714,69 +869,22 @@ public class UltrahumanDeviceSupport extends AbstractBTLESingleDeviceSupport { return; default: if (DeviceInfoProfile.ACTION_DEVICE_INFO.equals(action)) { - handleDeviceInfo(intent.getParcelableExtra(DeviceInfoProfile.EXTRA_DEVICE_INFO)); - return; + DeviceInfo info = intent.getParcelableExtra(DeviceInfoProfile.EXTRA_DEVICE_INFO); + if (info != null) { + handleDeviceInfo(info); + } } } } - } - /// simplify debugging and tracing - private class UHWrite extends WriteAction { - private final UltrahumanCharacteristic Characteristic; - - public UHWrite(UltrahumanCharacteristic chara, byte... value) { - super(resolve(chara), value); - Characteristic = chara; - } - - @Override - public boolean run(BluetoothGatt gatt) { - LOG.debug("GB>>UH write {} {}", Characteristic, StringUtils.bytesToHex(getValue())); - return super.run(gatt); - } - } - - /// simplify debugging and tracing - private class UHRead extends ReadAction { - private final UltrahumanCharacteristic Characteristic; - - public UHRead(UltrahumanCharacteristic chara) { - super(resolve(chara)); - Characteristic = chara; - } - - @Override - public boolean run(BluetoothGatt gatt) { - LOG.debug("GB>>UH read {}", Characteristic); - return super.run(gatt); - } - } - - /// calculate date/time on the fly to avoid setting an outdated value - private class UHSetTime extends UHWrite { - private byte[] actual; - - UHSetTime(UltrahumanCharacteristic chara) { - super(chara); - } - - @Override - public byte[] getValue(){ - if (actual == null){ - final Calendar calendar = DateTimeUtils.getCalendarUTC(); - final long millis = calendar.getTimeInMillis(); - final long epoc = Math.round(millis / 1000.0d); - - actual = new byte[]{ - OPERATION_SETTIME, - (byte) (epoc & 0xff), - (byte) ((epoc >> 8) & 0xff), - (byte) ((epoc >> 16) & 0xff), - (byte) ((epoc >> 24) & 0xff) - }; + private void changeExercise(byte exercise) { + TransactionBuilder builder = createTransactionBuilder("changeExercise"); + if (exercise != UltrahumanExercise.CHECK.Code) { + builder.write(UUID_COMMAND, exercise); } - return actual; + builder.write(UUID_COMMAND, OPERATION_CHECK_DATA); + + enqueue(builder); } } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/ultrahuman/UltrahumanWriteTime.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/ultrahuman/UltrahumanWriteTime.kt new file mode 100644 index 0000000000..a61b8be8d4 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/ultrahuman/UltrahumanWriteTime.kt @@ -0,0 +1,39 @@ +/* Copyright (C) 2025 Thomas Kuehne + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.service.devices.ultrahuman + +import android.bluetooth.BluetoothGattCharacteristic +import nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.UltrahumanConstants +import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.ConditionalWriteAction +import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils + +/** calculate date/time on the fly to avoid setting an outdated value */ +class UltrahumanWriteTime(characteristic: BluetoothGattCharacteristic) : + ConditionalWriteAction(characteristic) { + override fun checkCondition(): ByteArray { + val epoc: Long = DateTimeUtils.getEpochSeconds() + + val value = byteArrayOf( + UltrahumanConstants.OPERATION_SETTIME, + (epoc and 0xff).toByte(), + ((epoc shr 8) and 0xff).toByte(), + ((epoc shr 16) and 0xff).toByte(), + ((epoc shr 24) and 0xff).toByte() + ) + return value + } +} \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/DateTimeUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/DateTimeUtils.java index 1885b658ae..472786e720 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/DateTimeUtils.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/DateTimeUtils.java @@ -26,6 +26,7 @@ import java.text.FieldPosition; import java.text.ParseException; import java.text.ParsePosition; import java.text.SimpleDateFormat; +import java.time.Instant; import java.time.LocalDate; import java.util.Calendar; import java.util.Date; @@ -334,4 +335,17 @@ public class DateTimeUtils { SimpleDateFormat format = new SimpleDateFormat("HH:mm:ss", Locale.ROOT); return format.format(date); } + + /// number of seconds since UTC epoch of 1970-01-01T00:00:00Z + public static long getEpochSeconds() { + final long epoc; + if (GBApplication.isRunningOreoOrLater()) { + epoc = Instant.now().getEpochSecond(); + } else { + Calendar calendar = getCalendarUTC(); + long millis = calendar.getTimeInMillis(); + epoc = Math.round(millis / 1000.0d); + } + return epoc; + } } diff --git a/app/src/main/res/drawable/ic_pulmonology.xml b/app/src/main/res/drawable/ic_pulmonology.xml index a00a9b4a47..d23209e1ca 100644 --- a/app/src/main/res/drawable/ic_pulmonology.xml +++ b/app/src/main/res/drawable/ic_pulmonology.xml @@ -6,5 +6,5 @@ android:tint="?attr/colorControlNormal"> + android:pathData="M200,840Q149,840 114.5,805.5Q80,771 80,720L80,553L185,272Q197,239 227,219.5Q257,200 292,200Q337,200 368.5,232.5Q400,265 400,311L400,360L320,360L320,311Q320,298 311,289Q302,280 290,280Q280,280 271.5,285.5Q263,291 260,300L160,568L160,720Q160,737 171.5,748.5Q183,760 200,760L320,760Q337,760 348.5,748.5Q360,737 360,720L360,640L440,640L440,720Q440,771 405,805.5Q370,840 320,840L200,840ZM759,840L639,840Q589,840 554,805.5Q519,771 519,720L519,640L599,640L599,720Q599,737 610.5,748.5Q622,760 639,760L759,760Q776,760 787.5,748.5Q799,737 799,720L799,568L699,300Q695,291 687,285.5Q679,280 669,280Q656,280 647.5,289Q639,298 639,311L639,360L559,360L559,311Q559,265 590.5,232.5Q622,200 667,200Q702,200 731.5,219.5Q761,239 774,272L879,553L879,720Q879,771 844,805.5Q809,840 759,840ZM320,504L320,504L320,504L320,504Q320,504 320,504Q320,504 320,504L320,504Q320,504 320,504Q320,504 320,504L320,504L320,504Q320,504 320,504Q320,504 320,504Q320,504 320,504Q320,504 320,504L320,504L320,504L320,504L320,504ZM639,504L639,504L639,504L639,504L639,504L639,504Q639,504 639,504Q639,504 639,504Q639,504 639,504Q639,504 639,504L639,504L639,504Q639,504 639,504Q639,504 639,504L639,504Q639,504 639,504Q639,504 639,504L639,504L639,504ZM480,457L376,560L320,504L440,384L440,80L520,80L520,384L640,504L583,560L480,457Z"/> diff --git a/app/src/main/res/layout/activity_ultrahuman_breathing.xml b/app/src/main/res/layout/activity_ultrahuman_breathing.xml index 514a8c7889..6f9ef8e4b7 100644 --- a/app/src/main/res/layout/activity_ultrahuman_breathing.xml +++ b/app/src/main/res/layout/activity_ultrahuman_breathing.xml @@ -1,75 +1,184 @@ - + android:layout_height="match_parent"> - + android:columnCount="2"> - - - - - - - - - +