diff --git a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java
index fabbb0ba91..85b5cc473b 100644
--- a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java
+++ b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java
@@ -46,6 +46,8 @@ public class GBDaoGenerator {
private static final String SAMPLE_HRV_BASELINE_BALANCED_UPPER = "baselineBalancedUpper";
private static final String SAMPLE_HRV_STATUS_NUM = "statusNum";
private static final String SAMPLE_HRV_VALUE = "value";
+ private static final String SAMPLE_SPO2 = "spo2";
+ private static final String SAMPLE_STRESS = "stress";
private static final String SAMPLE_TEMPERATURE = "temperature";
private static final String SAMPLE_TEMPERATURE_TYPE = "temperatureType";
private static final String SAMPLE_WEIGHT_KG = "weightKg";
@@ -54,7 +56,7 @@ public class GBDaoGenerator {
public static void main(String[] args) throws Exception {
- final Schema schema = new Schema(97, MAIN_PACKAGE + ".entities");
+ final Schema schema = new Schema(98, MAIN_PACKAGE + ".entities");
Entity userAttributes = addUserAttributes(schema);
Entity user = addUserInfo(schema, userAttributes);
@@ -157,6 +159,9 @@ public class GBDaoGenerator {
addHuaweiActivitySample(schema, user, device);
+ addUltrahumanActivitySample(schema, user, device);
+ addUltrahumanDeviceStateSample(schema, user, device);
+
Entity huaweiWorkoutSummary = addHuaweiWorkoutSummarySample(schema, user, device);
addHuaweiWorkoutDataSample(schema, huaweiWorkoutSummary);
addHuaweiWorkoutPaceSample(schema, huaweiWorkoutSummary);
@@ -181,6 +186,13 @@ public class GBDaoGenerator {
addActivitySummary(schema, user, device);
addBatteryLevel(schema, device);
+
+ addGenericHeartRateSample(schema, user, device);
+ addGenericSpo2Sample(schema, user, device);
+ addGenericStressSample(schema, user, device);
+ addGenericHrvValueSample(schema, user, device);
+ addGenericTemperatureSample(schema, user, device);
+
new DaoGenerator().generateAll(schema, "app/src/main/java");
}
@@ -328,7 +340,7 @@ public class GBDaoGenerator {
Entity stressSample = addEntity(schema, "HuamiStressSample");
addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device);
stressSample.addIntProperty("typeNum").notNull().codeBeforeGetter(OVERRIDE);
- stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE);
+ stressSample.addIntProperty(SAMPLE_STRESS).notNull().codeBeforeGetter(OVERRIDE);
return stressSample;
}
@@ -336,7 +348,7 @@ public class GBDaoGenerator {
Entity spo2sample = addEntity(schema, "HuamiSpo2Sample");
addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device);
spo2sample.addIntProperty("typeNum").notNull().codeBeforeGetter(OVERRIDE);
- spo2sample.addIntProperty("spo2").notNull().codeBeforeGetter(OVERRIDE);
+ spo2sample.addIntProperty(SAMPLE_SPO2).notNull().codeBeforeGetter(OVERRIDE);
return spo2sample;
}
@@ -400,8 +412,8 @@ public class GBDaoGenerator {
activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
addHeartRateProperties(activitySample);
- activitySample.addIntProperty("stress");
- activitySample.addIntProperty("spo2");
+ activitySample.addIntProperty(SAMPLE_STRESS);
+ activitySample.addIntProperty(SAMPLE_SPO2);
return activitySample;
}
@@ -486,14 +498,14 @@ public class GBDaoGenerator {
private static Entity addCmfStressSample(Schema schema, Entity user, Entity device) {
Entity stressSample = addEntity(schema, "CmfStressSample");
addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device);
- stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE);
+ stressSample.addIntProperty(SAMPLE_STRESS).notNull().codeBeforeGetter(OVERRIDE);
return stressSample;
}
private static Entity addCmfSpo2Sample(Schema schema, Entity user, Entity device) {
Entity spo2sample = addEntity(schema, "CmfSpo2Sample");
addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device);
- spo2sample.addIntProperty("spo2").notNull().codeBeforeGetter(OVERRIDE);
+ spo2sample.addIntProperty(SAMPLE_SPO2).notNull().codeBeforeGetter(OVERRIDE);
return spo2sample;
}
@@ -551,14 +563,14 @@ public class GBDaoGenerator {
private static Entity addColmiStressSample(Schema schema, Entity user, Entity device) {
Entity stressSample = addEntity(schema, "ColmiStressSample");
addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device);
- stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE);
+ stressSample.addIntProperty(SAMPLE_STRESS).notNull().codeBeforeGetter(OVERRIDE);
return stressSample;
}
private static Entity addColmiSpo2Sample(Schema schema, Entity user, Entity device) {
Entity spo2sample = addEntity(schema, "ColmiSpo2Sample");
addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device);
- spo2sample.addIntProperty("spo2").notNull().codeBeforeGetter(OVERRIDE);
+ spo2sample.addIntProperty(SAMPLE_SPO2).notNull().codeBeforeGetter(OVERRIDE);
return spo2sample;
}
@@ -759,7 +771,7 @@ public class GBDaoGenerator {
private static Entity addHybridHRSpo2Sample(Schema schema, Entity user, Entity device) {
Entity spo2sample = addEntity(schema, "HybridHRSpo2Sample");
addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device);
- spo2sample.addIntProperty("spo2").notNull().codeBeforeGetter(OVERRIDE);
+ spo2sample.addIntProperty(SAMPLE_SPO2).notNull().codeBeforeGetter(OVERRIDE);
return spo2sample;
}
@@ -834,7 +846,7 @@ public class GBDaoGenerator {
private static Entity addGarminStressSample(Schema schema, Entity user, Entity device) {
Entity stressSample = addEntity(schema, "GarminStressSample");
addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device);
- stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE);
+ stressSample.addIntProperty(SAMPLE_STRESS).notNull().codeBeforeGetter(OVERRIDE);
return stressSample;
}
@@ -848,7 +860,7 @@ public class GBDaoGenerator {
private static Entity addGarminSpo2Sample(Schema schema, Entity user, Entity device) {
Entity spo2sample = addEntity(schema, "GarminSpo2Sample");
addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device);
- spo2sample.addIntProperty("spo2").notNull().codeBeforeGetter(OVERRIDE);
+ spo2sample.addIntProperty(SAMPLE_SPO2).notNull().codeBeforeGetter(OVERRIDE);
return spo2sample;
}
@@ -1337,7 +1349,7 @@ public class GBDaoGenerator {
Entity stressSample = addEntity(schema, "Wena3StressSample");
addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device);
stressSample.addIntProperty("typeNum").notNull().codeBeforeGetter(OVERRIDE);
- stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE);
+ stressSample.addIntProperty(SAMPLE_STRESS).notNull().codeBeforeGetter(OVERRIDE);
return stressSample;
}
@@ -1613,6 +1625,31 @@ public class GBDaoGenerator {
return workoutSectionsSample;
}
+ private static Entity addUltrahumanActivitySample(Schema schema, Entity user, Entity device) {
+ Entity sample = addEntity(schema, "UltrahumanActivitySample");
+
+ addCommonActivitySampleProperties("AbstractUltrahumanActivitySample", sample, user, device);
+ sample.addIntProperty(SAMPLE_RAW_KIND).notNull();
+ sample.addIntProperty(SAMPLE_HEART_RATE).notNull();
+ sample.addIntProperty(SAMPLE_RAW_INTENSITY).notNull();
+ sample.addIntProperty(SAMPLE_STEPS).notNull();
+
+ return sample;
+ }
+
+ private static Entity addUltrahumanDeviceStateSample(Schema schema, Entity user, Entity device) {
+ Entity sample = addEntity(schema, "UltrahumanDeviceStateSample");
+
+ addCommonTimeSampleProperties("AbstractTimeSample", sample, user, device);
+ sample.addByteArrayProperty(SAMPLE_RAW_KIND).notNull();
+
+ sample.addIntProperty("batteryLevel");
+ sample.addIntProperty("deviceState");
+ sample.addIntProperty("deviceTemperature");
+
+ return sample;
+ }
+
private static Entity addHuaweiDictData(Schema schema, Entity user, Entity device) {
Entity dictData = addEntity(schema, "HuaweiDictData");
@@ -1685,4 +1722,40 @@ public class GBDaoGenerator {
sample.addFloatProperty(SAMPLE_WEIGHT_KG).notNull().codeBeforeGetter(OVERRIDE);
return sample;
}
+
+ private static Entity addGenericHeartRateSample(Schema schema, Entity user, Entity device) {
+ Entity heartRateSample = addEntity(schema, "GenericHeartRateSample");
+ addCommonTimeSampleProperties("AbstractHeartRateSample", heartRateSample, user, device);
+ heartRateSample.addIntProperty(SAMPLE_HEART_RATE).notNull();
+ return heartRateSample;
+ }
+
+ private static Entity addGenericHrvValueSample(Schema schema, Entity user, Entity device) {
+ Entity hrvValueSample = addEntity(schema, "GenericHrvValueSample");
+ addCommonTimeSampleProperties("AbstractHrvValueSample", hrvValueSample, user, device);
+ hrvValueSample.addIntProperty(SAMPLE_HRV_VALUE).notNull();
+ return hrvValueSample;
+ }
+
+ private static Entity addGenericSpo2Sample(Schema schema, Entity user, Entity device) {
+ Entity spo2sample = addEntity(schema, "GenericSpo2Sample");
+ addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device);
+ spo2sample.addIntProperty(SAMPLE_SPO2).notNull();
+ return spo2sample;
+ }
+
+ private static Entity addGenericStressSample(Schema schema, Entity user, Entity device) {
+ Entity stressSample = addEntity(schema, "GenericStressSample");
+ addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device);
+ stressSample.addIntProperty(SAMPLE_STRESS).notNull();
+ return stressSample;
+ }
+
+ private static Entity addGenericTemperatureSample(Schema schema, Entity user, Entity device) {
+ Entity temperatureSample = addEntity(schema, "GenericTemperatureSample");
+ addCommonTimeSampleProperties("AbstractTemperatureSample", temperatureSample, user, device);
+ temperatureSample.addFloatProperty(SAMPLE_TEMPERATURE).notNull();
+ temperatureSample.addIntProperty(SAMPLE_TEMPERATURE_TYPE).notNull();
+ return temperatureSample;
+ }
}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/GenericHeartRateSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/GenericHeartRateSampleProvider.java
new file mode 100644
index 0000000000..e740453327
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/GenericHeartRateSampleProvider.java
@@ -0,0 +1,56 @@
+/* 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.devices;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericHeartRateSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericHeartRateSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class GenericHeartRateSampleProvider extends AbstractTimeSampleProvider {
+ public GenericHeartRateSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao getSampleDao() {
+ return getSession().getGenericHeartRateSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return GenericHeartRateSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return GenericHeartRateSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public GenericHeartRateSample createSample() {
+ return new GenericHeartRateSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/GenericHrvValueSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/GenericHrvValueSampleProvider.java
new file mode 100644
index 0000000000..d20c4f21fa
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/GenericHrvValueSampleProvider.java
@@ -0,0 +1,56 @@
+/* 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.devices;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericHrvValueSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericHrvValueSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class GenericHrvValueSampleProvider extends AbstractTimeSampleProvider {
+ public GenericHrvValueSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao getSampleDao() {
+ return getSession().getGenericHrvValueSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return GenericHrvValueSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return GenericHrvValueSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public GenericHrvValueSample createSample() {
+ return new GenericHrvValueSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/GenericSpo2SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/GenericSpo2SampleProvider.java
new file mode 100644
index 0000000000..41c3767448
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/GenericSpo2SampleProvider.java
@@ -0,0 +1,57 @@
+/* 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.devices;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericSpo2Sample;
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericSpo2SampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class GenericSpo2SampleProvider extends AbstractTimeSampleProvider {
+ public GenericSpo2SampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao getSampleDao() {
+ return getSession().getGenericSpo2SampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return GenericSpo2SampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return GenericSpo2SampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public GenericSpo2Sample createSample() {
+ return new GenericSpo2Sample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/GenericStressSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/GenericStressSampleProvider.java
new file mode 100644
index 0000000000..ddd052b3d4
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/GenericStressSampleProvider.java
@@ -0,0 +1,56 @@
+/* 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.devices;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericStressSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericStressSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class GenericStressSampleProvider extends AbstractTimeSampleProvider {
+ public GenericStressSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao getSampleDao() {
+ return getSession().getGenericStressSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return GenericStressSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return GenericStressSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public GenericStressSample createSample() {
+ return new GenericStressSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/GenericTemperatureSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/GenericTemperatureSampleProvider.java
new file mode 100644
index 0000000000..3643514db9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/GenericTemperatureSampleProvider.java
@@ -0,0 +1,56 @@
+/* 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.devices;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericTemperatureSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericTemperatureSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class GenericTemperatureSampleProvider extends AbstractTimeSampleProvider {
+ public GenericTemperatureSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao getSampleDao() {
+ return getSession().getGenericTemperatureSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return GenericTemperatureSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return GenericTemperatureSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public GenericTemperatureSample createSample() {
+ return new GenericTemperatureSample();
+ }
+}
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
new file mode 100644
index 0000000000..7083d1d76b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanConstants.java
@@ -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.devices.ultrahuman;
+
+import java.util.UUID;
+
+public class UltrahumanConstants {
+ public static final String ACTION_AIRPLANE_MODE = "nodomain.freeyourgadget.gadgetbridge.ultrahuman.action.AIRPLANE_MODE";
+
+ public static final UUID UUID_SERVICE_COMMAND = 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_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 byte OPERATION_SETTIME = 0x02;
+ public static final byte OPERATION_GET_RECORDINGS = 0x04;
+ 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_PING = 0x59;
+ public static final byte OPERATION_ACTIVATE_AIRPLANE_MODE = 0x70;
+ public static final byte OPERATION_RESET = (byte) 0x98;
+}
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
new file mode 100644
index 0000000000..cb1e15968f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanDeviceCardAction.java
@@ -0,0 +1,67 @@
+/* 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.devices.ultrahuman;
+
+import android.content.Context;
+import android.content.Intent;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCardAction;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class UltrahumanDeviceCardAction implements DeviceCardAction {
+
+ private final int Icon;
+ private final int Description;
+ private final int Question;
+
+ private final String Action;
+
+ public UltrahumanDeviceCardAction(int icon, int description, int question, String action) {
+ Icon = icon;
+ Description = description;
+ Question = question;
+ Action = action;
+ }
+
+ @Override
+ public int getIcon(GBDevice device) {
+ return Icon;
+ }
+
+ @Override
+ public String getDescription(GBDevice device, Context context) {
+ return context.getString(Description);
+ }
+
+ @Override
+ public void onClick(GBDevice device, Context context) {
+ new MaterialAlertDialogBuilder(context)
+ .setTitle(Description)
+ .setMessage(Question)
+ .setIcon(Icon)
+ .setPositiveButton(android.R.string.yes, (dialog, whichButton) -> {
+ final Intent intent = new Intent(Action);
+ intent.putExtra(GBDevice.EXTRA_DEVICE, device);
+ context.sendBroadcast(intent);
+ })
+ .setNegativeButton(android.R.string.no, null)
+ .show();
+ }
+}
\ No newline at end of file
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
new file mode 100644
index 0000000000..32701fbd3f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/UltrahumanDeviceCoordinator.java
@@ -0,0 +1,229 @@
+/* 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.devices.ultrahuman;
+
+import androidx.annotation.NonNull;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCardAction;
+import nodomain.freeyourgadget.gadgetbridge.devices.GenericHeartRateSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.GenericHrvValueSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.GenericSpo2SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.GenericStressSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.GenericTemperatureSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.samples.UltrahumanActivitySampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericHeartRateSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericHrvValueSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericSpo2SampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericStressSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericTemperatureSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.UltrahumanActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.UltrahumanDeviceStateSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.HeartRateSample;
+import nodomain.freeyourgadget.gadgetbridge.model.HrvValueSample;
+import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample;
+import nodomain.freeyourgadget.gadgetbridge.model.StressSample;
+import nodomain.freeyourgadget.gadgetbridge.model.TemperatureSample;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.ultrahuman.UltrahumanDeviceSupport;
+
+public class UltrahumanDeviceCoordinator extends AbstractBLEDeviceCoordinator {
+ private static final Logger LOG = LoggerFactory.getLogger(UltrahumanDeviceCoordinator.class);
+
+ @Override
+ public List getCustomActions() {
+ // TODO - use an airplane icon instead
+ DeviceCardAction airplaneMode = new UltrahumanDeviceCardAction(R.drawable.ic_circle, R.string.ultrahuman_airplane_mode_title, R.string.ultrahuman_airplane_mode_question, UltrahumanConstants.ACTION_AIRPLANE_MODE);
+
+ return Collections.singletonList(airplaneMode);
+ }
+
+ @Override
+ protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
+ final Long deviceId = device.getId();
+
+ final Map, Property> daoMap = new HashMap, Property>() {{
+ put(session.getGenericHeartRateSampleDao(), GenericHeartRateSampleDao.Properties.DeviceId);
+ put(session.getGenericHrvValueSampleDao(), GenericHrvValueSampleDao.Properties.DeviceId);
+ put(session.getGenericSpo2SampleDao(), GenericSpo2SampleDao.Properties.DeviceId);
+ put(session.getGenericStressSampleDao(), GenericStressSampleDao.Properties.DeviceId);
+ put(session.getGenericTemperatureSampleDao(), GenericTemperatureSampleDao.Properties.DeviceId);
+ put(session.getUltrahumanActivitySampleDao(), UltrahumanActivitySampleDao.Properties.DeviceId);
+ put(session.getUltrahumanDeviceStateSampleDao(), UltrahumanDeviceStateSampleDao.Properties.DeviceId);
+ }};
+
+ for (final Map.Entry, Property> e : daoMap.entrySet()) {
+ e.getKey().queryBuilder()
+ .where(e.getValue().eq(deviceId))
+ .buildDelete().executeDeleteWithoutDetachingEntities();
+ }
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_smartring;
+ }
+
+ @Override
+ public int getDisabledIconResource() {
+ return R.drawable.ic_device_smartring_disabled;
+ }
+
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_ultrahuma_ring_air;
+ }
+
+ @NonNull
+ @Override
+ public Class extends DeviceSupport> getDeviceSupportClass() {
+ return UltrahumanDeviceSupport.class;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Ultrahuman Healthcare Pvt Ltd";
+ }
+
+ @Override
+ public TimeSampleProvider extends HrvValueSample> getHrvValueSampleProvider(GBDevice device, DaoSession session) {
+ return new GenericHrvValueSampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider extends HeartRateSample> getHeartRateMaxSampleProvider(GBDevice device, DaoSession session) {
+ return new GenericHeartRateSampleProvider(device, session);
+ }
+
+ @Override
+ public SampleProvider extends ActivitySample> getSampleProvider(final GBDevice device, final DaoSession session) {
+ return new UltrahumanActivitySampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider extends Spo2Sample> getSpo2SampleProvider(GBDevice device, DaoSession session) {
+ return new GenericSpo2SampleProvider(device, session);
+ }
+
+ @Override
+ public TimeSampleProvider extends StressSample> getStressSampleProvider(GBDevice device, DaoSession session) {
+ return new GenericStressSampleProvider(device, session);
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("UH_[0-9A-F]{12}");
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_time_sync
+ };
+ }
+
+ @Override
+ public TimeSampleProvider extends TemperatureSample> getTemperatureSampleProvider(GBDevice device, DaoSession session) {
+ return new GenericTemperatureSampleProvider(device, session);
+ }
+
+ @Override
+ public boolean isExperimental() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsContinuousTemperature() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsHrvMeasurement() {
+ // TODO - needs getHrvSummarySampleProvider in addition to the implemented getHrvValueSampleProvider
+ return false;
+ }
+
+ @Override
+ public boolean supportsManualHeartRateMeasurement(final GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public boolean supportsSleepMeasurement() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsSpo2(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsStepCounter() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsTemperatureMeasurement() {
+ return true;
+ }
+
+ @Override
+ public boolean supportsSpeedzones() {
+ return false;
+ }
+
+ @Override
+ public boolean supportsStressMeasurement() {
+ return true;
+ }
+}
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
new file mode 100644
index 0000000000..7f7b8e3113
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/samples/UltrahumanActivitySampleProvider.java
@@ -0,0 +1,106 @@
+/* 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.devices.ultrahuman.samples;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.UltrahumanActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.UltrahumanActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+public class UltrahumanActivitySampleProvider extends AbstractSampleProvider {
+ public UltrahumanActivitySampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ }
+
+ public static ActivityKind normalizeTypeStatic(int rawType) {
+ switch (rawType) {
+ case 5:
+ return ActivityKind.BREATHWORK;
+ case 6:
+ return ActivityKind.EXERCISE;
+ default:
+ return (rawType >= 100) ? ActivityKind.NOT_WORN : ActivityKind.UNKNOWN;
+ }
+ }
+
+ public static float normalizeIntensityStatic(int rawIntensity) {
+ return rawIntensity / 150f;
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao getSampleDao() {
+ return getSession().getUltrahumanActivitySampleDao();
+ }
+
+ @Nullable
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return UltrahumanActivitySampleDao.Properties.RawKind;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return UltrahumanActivitySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return UltrahumanActivitySampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ return normalizeTypeStatic(rawType);
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ switch (activityKind) {
+ case UNKNOWN:
+ return 1;
+ case BREATHWORK:
+ return 5;
+ case EXERCISE:
+ return 6;
+ case NOT_WORN:
+ return 100;
+ default:
+ return 0;
+ }
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ return normalizeIntensityStatic(rawIntensity);
+ }
+
+ @Override
+ public UltrahumanActivitySample createActivitySample() {
+ return new UltrahumanActivitySample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/samples/UltrahumanDeviceStateSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/samples/UltrahumanDeviceStateSampleProvider.java
new file mode 100644
index 0000000000..d9fc1bd22c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/ultrahuman/samples/UltrahumanDeviceStateSampleProvider.java
@@ -0,0 +1,57 @@
+/* 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.devices.ultrahuman.samples;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.UltrahumanDeviceStateSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.UltrahumanDeviceStateSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class UltrahumanDeviceStateSampleProvider extends AbstractTimeSampleProvider {
+ public UltrahumanDeviceStateSampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao getSampleDao() {
+ return getSession().getUltrahumanDeviceStateSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return UltrahumanDeviceStateSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return UltrahumanDeviceStateSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public UltrahumanDeviceStateSample createSample() {
+ return new UltrahumanDeviceStateSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractUltrahumanActivitySample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractUltrahumanActivitySample.java
new file mode 100644
index 0000000000..091b4a500b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/AbstractUltrahumanActivitySample.java
@@ -0,0 +1,43 @@
+/* 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.entities;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.samples.UltrahumanActivitySampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+public abstract class AbstractUltrahumanActivitySample extends AbstractActivitySample {
+ @Override
+ public ActivityKind getKind() {
+ return UltrahumanActivitySampleProvider.normalizeTypeStatic(getRawKind());
+ }
+
+ @Override
+ public float getIntensity() {
+ return UltrahumanActivitySampleProvider.normalizeIntensityStatic(getRawIntensity());
+ }
+
+ @Override
+ public int getDistanceCm() {
+ return -1;
+ }
+
+ @Override
+ public int getActiveCalories() {
+ return -1;
+ }
+}
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 7d8df74671..3c46ee1bfc 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java
@@ -284,6 +284,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.soundcore.q30.SoundcoreQ30Co
import nodomain.freeyourgadget.gadgetbridge.devices.supercars.SuperCarsCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.test.TestDeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.tlw64.TLW64Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.UltrahumanDeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.um25.Coordinator.UM25Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.vesc.VescCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.vibratissimo.VibratissimoCoordinator;
@@ -625,7 +626,8 @@ public enum DeviceType {
CYCLING_SENSOR(CyclingSensorCoordinator.class),
BLE_GATT_CLIENT(BleGattClientCoordinator.class),
MARSTEK_B2500(MarstekB2500DeviceCoordinator.class),
- TEST(TestDeviceCoordinator.class);
+ TEST(TestDeviceCoordinator.class),
+ ULTRAHUMAN_RING_AIR(UltrahumanDeviceCoordinator.class);
private DeviceCoordinator coordinator;
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
new file mode 100644
index 0000000000..0ab006692c
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/ultrahuman/UltrahumanDeviceSupport.java
@@ -0,0 +1,552 @@
+/* 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.BluetoothGatt;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.widget.Toast;
+
+import androidx.core.content.ContextCompat;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.UUID;
+
+import nodomain.freeyourgadget.gadgetbridge.GBApplication;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
+import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo;
+import nodomain.freeyourgadget.gadgetbridge.devices.GenericHeartRateSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.GenericHrvValueSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.GenericSpo2SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.GenericStressSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.GenericTemperatureSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.UltrahumanConstants;
+import nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.samples.UltrahumanActivitySampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.samples.UltrahumanDeviceStateSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericHeartRateSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericHrvValueSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericSpo2Sample;
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericStressSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericTemperatureSample;
+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.DeviceType;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.GattService;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.IntentListener;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.deviceinfo.DeviceInfoProfile;
+import nodomain.freeyourgadget.gadgetbridge.service.serial.GBDeviceProtocol;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
+
+public class UltrahumanDeviceSupport extends AbstractBTLEDeviceSupport {
+ private static final Logger LOG = LoggerFactory.getLogger(UltrahumanDeviceSupport.class);
+ private BroadcastReceiver CommandReceiver;
+ private int FetchTo;
+ private int FetchFrom;
+ private int FetchCurrent;
+
+ public UltrahumanDeviceSupport(DeviceType type) {
+ super(LOG);
+
+ addSupportedService(UltrahumanConstants.UUID_SERVICE_COMMAND);
+ addSupportedService(UltrahumanConstants.UUID_SERVICE_STATE);
+ addSupportedService(GattService.UUID_SERVICE_DEVICE_INFORMATION);
+
+ DeviceInfoProfile deviceProfile = new DeviceInfoProfile<>(this);
+ deviceProfile.addListener(new UltrahumanIntentListener());
+ addSupportedProfile(deviceProfile);
+ }
+
+ @Override
+ public void dispose() {
+ BroadcastReceiver receiver = CommandReceiver;
+ CommandReceiver = null;
+
+ if (receiver != null) {
+ getContext().unregisterReceiver(receiver);
+ }
+
+ super.dispose();
+ }
+
+ @Override
+ protected TransactionBuilder initializeDevice(TransactionBuilder builder) {
+ // reset to avoid funny states for re-connect
+ FetchTo = -1;
+ FetchFrom = -1;
+ FetchCurrent = -1;
+
+ // required for DB
+ if (getDevice().getFirmwareVersion() == null) {
+ getDevice().setFirmwareVersion("N/A");
+ getDevice().setFirmwareVersion2("N/A");
+ }
+
+ builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext()));
+
+ if (CommandReceiver == null) {
+ CommandReceiver = new UltrahumanBroadcastReceiver();
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(UltrahumanConstants.ACTION_AIRPLANE_MODE);
+ ContextCompat.registerReceiver(getContext(), CommandReceiver, filter, ContextCompat.RECEIVER_EXPORTED);
+ }
+
+ // 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));
+
+ // TODO - implement a "OPERATION_PING until answer" logic instead of waits
+ // 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 * 3); //BluetoothGatt.onConnectionUpdated typically reports interval=48
+
+ builder.read(getCharacteristic(UltrahumanConstants.UUID_CHARACTERISTIC_STATE));
+
+ builder.wait(48 * 2);
+
+ builder.notify(getCharacteristic(UltrahumanConstants.UUID_CHARACTERISTIC_RESPONSE), true);
+ builder.notify(getCharacteristic(UltrahumanConstants.UUID_CHARACTERISTIC_STATE), true);
+
+ builder.write(getCharacteristic(UltrahumanConstants.UUID_CHARACTERISTIC_COMMAND), new byte[]{UltrahumanConstants.OPERATION_PING});
+
+ boolean timeSync = getDevicePrefs().getBoolean(DeviceSettingsPreferenceConst.PREF_TIME_SYNC, true);
+ if (timeSync) {
+ builder.add(new UltrahumanSetTimeAction(getCharacteristic(UltrahumanConstants.UUID_CHARACTERISTIC_COMMAND)));
+ }
+
+ builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZED, getContext()));
+
+ 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());
+
+ String task = getContext().getString(R.string.busy_task_fetch_activity_data);
+ getDevice().setBusyTask(task);
+ getDevice().sendDeviceUpdateIntent(getContext());
+
+ FetchFrom = -1;
+ FetchTo = -1;
+ FetchCurrent = -1;
+
+ TransactionBuilder builder = new TransactionBuilder("onFetchRecordedData");
+ builder.write(getCharacteristic(UltrahumanConstants.UUID_CHARACTERISTIC_COMMAND), new byte[]{UltrahumanConstants.OPERATION_GET_FIRST_RECORDING_NR});
+ builder.write(getCharacteristic(UltrahumanConstants.UUID_CHARACTERISTIC_COMMAND), new byte[]{UltrahumanConstants.OPERATION_GET_LAST_RECORDING_NR});
+
+ if (isConnected()) {
+ builder.queue(getQueue());
+ } else {
+ GB.toast(getContext(), R.string.devicestatus_disconnected, Toast.LENGTH_LONG, GB.ERROR);
+ }
+ }
+
+ @Override
+ public boolean onCharacteristicRead(final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic, final int status) {
+ if (super.onCharacteristicRead(gatt, characteristic, status)) {
+ return true;
+ }
+
+ if (UltrahumanConstants.UUID_CHARACTERISTIC_STATE.equals(characteristic.getUuid())) {
+ if (status == BluetoothGatt.GATT_SUCCESS) {
+ return decodeDeviceState(characteristic.getValue());
+ }
+ }
+
+ return false;
+ }
+
+ @Override
+ public boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
+ if (super.onCharacteristicChanged(gatt, characteristic)) {
+ return true;
+ }
+
+ UUID characteristicUUID = characteristic.getUuid();
+ byte[] raw = characteristic.getValue();
+
+ if (UltrahumanConstants.UUID_CHARACTERISTIC_STATE.equals(characteristicUUID)) {
+ return decodeDeviceState(raw);
+ } else if (UltrahumanConstants.UUID_CHARACTERISTIC_RESPONSE.equals(characteristicUUID)) {
+ if (raw.length < 5) {
+ LOG.error("received unexpectedly short BLE command response: {}", StringUtils.bytesToHex(raw));
+ return false;
+ }
+
+ byte op = raw[0];
+ byte success = raw[1];
+ byte result = raw[2];
+ // ignore checksums for now - algorithm is unknown
+ //byte chk1 = raw[raw.length-1];
+ //byte chk2 = raw[raw.length-2];
+
+ if (success == 0x00) {
+ LOG.debug("received BLE command response op={}, success={}, result={} : {}", op, success, result, StringUtils.bytesToHex(raw));
+ } else if ((0xFF & success) == 0xFF) {
+ LOG.warn("received BLE command response op={}, success={}, result={} : {}", op, success, result, StringUtils.bytesToHex(raw));
+ } else {
+ LOG.info("received BLE command response op={}, success={}, result={} : {}", op, success, result, StringUtils.bytesToHex(raw));
+ }
+
+ Context context = getContext();
+
+ switch (op) {
+ case UltrahumanConstants.OPERATION_GET_RECORDINGS:
+ return decodeRecordings(raw);
+ case UltrahumanConstants.OPERATION_PING:
+ if (raw[1] != 0x00) {
+ String message = getContext().getString(R.string.ultrahuman_unhandled_error_response, raw[1], raw[0]);
+ GB.toast(getContext(), message, Toast.LENGTH_LONG, GB.ERROR);
+ }
+ return true;
+ case UltrahumanConstants.OPERATION_ACTIVATE_AIRPLANE_MODE:
+ switch (raw[2]) {
+ 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;
+ }
+ LOG.warn("set airplane mode - unknown error: {} ", StringUtils.bytesToHex(raw));
+ GB.toast(context, context.getString(R.string.ultrahuman_airplane_mode_unknown), Toast.LENGTH_LONG, GB.ERROR);
+ return false;
+
+ case UltrahumanConstants.OPERATION_GET_FIRST_RECORDING_NR:
+ if (raw[1] == 0x00 || raw[2] == 0x01) {
+ FetchFrom = BLETypeConversions.toUint16(raw, 3);
+ if (FetchTo != -1) {
+ fetchRecordingActually();
+ }
+ return true;
+ } else {
+ String message = getContext().getString(R.string.ultrahuman_unhandled_error_response, raw[1], raw[0]);
+ GB.toast(getContext(), message, Toast.LENGTH_LONG, GB.ERROR);
+ fetchRecordedDataFinished();
+ }
+ return false;
+
+ case UltrahumanConstants.OPERATION_GET_LAST_RECORDING_NR:
+ if (raw[1] == 0x00 && raw[2] == 0x01) {
+ FetchTo = BLETypeConversions.toUint16(raw, 3);
+ if (FetchFrom != -1) {
+ fetchRecordingActually();
+ }
+ return true;
+ } else {
+ String message = getContext().getString(R.string.ultrahuman_unhandled_error_response, raw[1], raw[0]);
+ GB.toast(getContext(), message, Toast.LENGTH_LONG, GB.ERROR);
+ fetchRecordedDataFinished();
+ }
+ return false;
+
+ case UltrahumanConstants.OPERATION_SETTIME:
+ if (raw[1] != 0x00 || raw[2] != 0x01) {
+ String message = getContext().getString(R.string.ultrahuman_unhandled_error_response, raw[1], raw[0]);
+ GB.toast(getContext(), message, Toast.LENGTH_LONG, GB.ERROR);
+ }
+ return true;
+ default:
+ LOG.error("unhandled BLE command response: {} ", StringUtils.bytesToHex(raw));
+ return false;
+ }
+ }
+
+ LOG.error("unhandled characteristics {} - {} ", characteristicUUID, StringUtils.bytesToHex(raw));
+ return false;
+ }
+
+
+ @Override
+ public boolean useAutoConnect() {
+ return false;
+ }
+
+ private void fetchRecordingActually() {
+ sendCommand("GetRecordingsCommand", new byte[]{UltrahumanConstants.OPERATION_GET_RECORDINGS, (byte) (FetchFrom & 0xFF), (byte) ((FetchFrom >> 8) & 0xFF)});
+ }
+
+ private boolean decodeRecordings(byte[] raw) {
+ if (raw[1] != 0) {
+ if ((raw[1] & 0xFF) == 0xEE) {
+ LOG.warn("no historic data recorded");
+ } else {
+ String message = getContext().getString(R.string.ultrahuman_unhandled_error_response, raw[1], raw[0]);
+ GB.toast(getContext(), message, Toast.LENGTH_LONG, GB.ERROR);
+ }
+ fetchRecordedDataFinished();
+ return raw.length == 5;
+ }
+
+ boolean success = true;
+ try (DBHandler db = GBApplication.acquireDB()) {
+ GBDevice device = getDevice();
+ DaoSession session = db.getDaoSession();
+
+ 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);
+ }
+ } catch (Exception e) {
+ LOG.error("Error acquiring database for recording historic sample", e);
+ }
+
+ if (FetchCurrent >= FetchTo || raw[2] < 7) {
+ fetchRecordedDataFinished();
+ }
+ return success;
+ }
+
+ private boolean decodeRecording(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));
+ return false;
+ }
+
+ int timestampPPG = BLETypeConversions.toUint32(raw, start);
+ int heartRate = BLETypeConversions.toUnsigned(raw[start + 4]);
+ int HRV = BLETypeConversions.toUnsigned(raw[start + 5]);
+ int spo2 = BLETypeConversions.toUnsigned(raw[start + 6]);
+ int recordType = BLETypeConversions.toUnsigned(raw[start + 7]);
+
+ int timestampTemp = BLETypeConversions.toUint32(raw, start + 8);
+ float temperatureMax = Float.intBitsToFloat(BLETypeConversions.toUint32(raw, start + 12));
+ float temperatureMin = Float.intBitsToFloat(BLETypeConversions.toUint32(raw, start + 16));
+
+ int timestampActivity = BLETypeConversions.toUint32(raw, start + 20);
+ int rawIntensity = BLETypeConversions.toUint16(raw[start + 24]);
+ int steps = BLETypeConversions.toUint16(raw[start + 26]);
+ int stress = (BLETypeConversions.toUint16(raw[start + 28]) * 100) / 255;
+
+ 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());
+ }
+ }
+
+ FetchCurrent = Integer.max(FetchCurrent, index);
+
+ LOG.debug("record[{}]: timeA={}, heartRate={}, HRV={}, spo2={}, recordType={}, timestampTemp={}, tempMax={}, tempMin={}," + "timeC={}, rawIntensity={}, steps={}, stress={}", index, timestampPPG, heartRate, HRV, spo2, recordType, timestampTemp, temperatureMax, temperatureMin, timestampActivity, rawIntensity, steps, stress);
+
+ if (heartRate != 0) {
+ GenericHeartRateSampleProvider provider = new GenericHeartRateSampleProvider(device, session);
+ GenericHeartRateSample sample = new GenericHeartRateSample(timestampPPG * 1000L, deviceId, userId, heartRate);
+ provider.addSample(sample);
+ }
+
+ if (HRV != 0) {
+ GenericHrvValueSampleProvider provider = new GenericHrvValueSampleProvider(device, session);
+ GenericHrvValueSample sample = new GenericHrvValueSample(timestampPPG * 1000L, deviceId, userId, HRV);
+ provider.addSample(sample);
+ }
+
+ if (spo2 != 0) {
+ GenericSpo2SampleProvider provider = new GenericSpo2SampleProvider(device, session);
+ GenericSpo2Sample sample = new GenericSpo2Sample(timestampPPG * 1000L, deviceId, userId, spo2);
+ provider.addSample(sample);
+ }
+
+ if (temperatureMax != 0.0f || temperatureMin != 0.0f) {
+ float temperature = (temperatureMax + temperatureMin) / 2f;
+ GenericTemperatureSampleProvider provider = new GenericTemperatureSampleProvider(device, session);
+ GenericTemperatureSample sample = new GenericTemperatureSample(timestampTemp * 1000L, deviceId, userId, temperature, 0);
+ provider.addSample(sample);
+ }
+
+ if (stress != 0) {
+ GenericStressSampleProvider provider = new GenericStressSampleProvider(device, session);
+ GenericStressSample sample = new GenericStressSample(timestampActivity * 1000L, deviceId, userId, stress);
+ provider.addSample(sample);
+ }
+
+ if (rawIntensity != 0 || steps != 0 || heartRate != 0) {
+ int hr = (heartRate == 0) ? -1 : heartRate;
+ UltrahumanActivitySampleProvider provider = new UltrahumanActivitySampleProvider(device, session);
+ UltrahumanActivitySample sample = new UltrahumanActivitySample(timestampActivity, deviceId, userId, recordType, hr, rawIntensity, steps);
+ provider.addGBActivitySample(sample);
+ }
+
+ return true;
+ }
+
+ private boolean decodeDeviceState(byte[] raw) {
+ boolean success = false;
+
+ BatteryState batteryState = BatteryState.UNKNOWN;
+ Integer batteryLevel = null;
+ Integer deviceState = null;
+ Integer deviceTemperature = null;
+
+ try (DBHandler db = GBApplication.acquireDB()) {
+ if (raw.length != 7) {
+ LOG.warn("received Device State with unexpected length {}: {}", raw.length, StringUtils.bytesToHex(raw));
+ } else {
+ batteryLevel = 0xFF & raw[0];
+ // decoding of 1..4 is unknown
+ deviceState = 0xFF & raw[5];
+ deviceTemperature = 0xFF & raw[6];
+
+ switch (deviceState) {
+ case 0x00:
+ batteryState = BatteryState.BATTERY_NORMAL;
+ break;
+ case 0x03:
+ batteryState = BatteryState.BATTERY_CHARGING;
+ break;
+ default:
+ LOG.warn("DeviceState contains unhandled device state {}: {}", raw[5], StringUtils.bytesToHex(raw));
+ }
+ }
+
+ GBDevice device = getDevice();
+ DaoSession session = db.getDaoSession();
+
+ long now = System.currentTimeMillis();
+ Long userId = DBHelper.getUser(session).getId();
+ Long deviceId = DBHelper.getDevice(device, session).getId();
+
+ UltrahumanDeviceStateSample sample = new UltrahumanDeviceStateSample(now, deviceId, userId, raw, batteryLevel, deviceState, deviceTemperature);
+
+ UltrahumanDeviceStateSampleProvider sampleProvider = new UltrahumanDeviceStateSampleProvider(device, session);
+ 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));
+ }
+
+ GBDeviceEventBatteryInfo batteryEvent = new GBDeviceEventBatteryInfo();
+ batteryEvent.level = (batteryLevel == null) ? -1 : batteryLevel;
+ batteryEvent.state = batteryState;
+ evaluateGBDeviceEvent(batteryEvent);
+
+ return success;
+ }
+
+ private void handleDeviceInfo(nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.deviceinfo.DeviceInfo info) {
+ LOG.debug("handleDeviceInfo: {}", info);
+ GBDeviceEventVersionInfo versionCmd = new GBDeviceEventVersionInfo();
+ versionCmd.fwVersion = info.getFirmwareRevision();
+ versionCmd.fwVersion2 = info.getSerialNumber();
+ versionCmd.hwVersion = info.getHardwareRevision();
+ handleGBDeviceEvent(versionCmd);
+ }
+
+ private void fetchRecordedDataFinished() {
+ GB.updateTransferNotification(null, "", false, 100, getContext());
+ LOG.info("Sync finished!");
+ getDevice().unsetBusyTask();
+ getDevice().sendDeviceUpdateIntent(getContext());
+ GB.signalActivityDataFinish(getDevice());
+ }
+
+ public void activateAirplaneMode() {
+ sendCommand("ActivateAirplaneMode", new byte[]{UltrahumanConstants.OPERATION_ACTIVATE_AIRPLANE_MODE});
+ }
+
+ @Override
+ public void onReset(int flags) {
+ if ((flags & GBDeviceProtocol.RESET_FLAGS_FACTORY_RESET) == GBDeviceProtocol.RESET_FLAGS_FACTORY_RESET) {
+ sendCommand("onReset", new byte[]{UltrahumanConstants.OPERATION_RESET});
+ }
+ }
+
+ @Override
+ public void onSetTime() {
+ TransactionBuilder builder = new TransactionBuilder("onSetTime");
+ UltrahumanSetTimeAction action = new UltrahumanSetTimeAction(getCharacteristic(UltrahumanConstants.UUID_CHARACTERISTIC_COMMAND));
+ builder.add(action);
+
+ if (isConnected()) {
+ builder.queue(getQueue());
+ } else {
+ GB.toast(getContext(), R.string.devicestatus_disconnected, Toast.LENGTH_LONG, GB.ERROR);
+ }
+ }
+
+ private void sendCommand(String taskName, byte[] contents) {
+ LOG.debug("sendCommand {} : {}", taskName, StringUtils.bytesToHex(contents));
+ TransactionBuilder builder = new TransactionBuilder(taskName);
+ builder.write(getCharacteristic(UltrahumanConstants.UUID_CHARACTERISTIC_COMMAND), contents);
+
+ if (isConnected()) {
+ builder.queue(getQueue());
+ } else {
+ GB.toast(getContext(), R.string.devicestatus_disconnected, Toast.LENGTH_LONG, GB.ERROR);
+ }
+ }
+
+ private class UltrahumanBroadcastReceiver extends BroadcastReceiver {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ final GBDevice device = intent.getParcelableExtra(GBDevice.EXTRA_DEVICE);
+ if (device != null && !device.equals(getDevice())) {
+ // this intent is for another device
+ return;
+ }
+
+ String action = intent.getAction();
+ if (UltrahumanConstants.ACTION_AIRPLANE_MODE.equals(action)) {
+ activateAirplaneMode();
+ }
+ }
+ }
+
+ private class UltrahumanIntentListener implements IntentListener {
+ @Override
+ public void notify(Intent intent) {
+ String action = intent.getAction();
+ if (DeviceInfoProfile.ACTION_DEVICE_INFO.equals(action)) {
+ handleDeviceInfo(intent.getParcelableExtra(DeviceInfoProfile.EXTRA_DEVICE_INFO));
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/ultrahuman/UltrahumanSetTimeAction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/ultrahuman/UltrahumanSetTimeAction.java
new file mode 100644
index 0000000000..8a64e66fe8
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/ultrahuman/UltrahumanSetTimeAction.java
@@ -0,0 +1,50 @@
+/* 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.BluetoothGatt;
+import android.bluetooth.BluetoothGattCharacteristic;
+
+import java.util.Calendar;
+
+import nodomain.freeyourgadget.gadgetbridge.devices.ultrahuman.UltrahumanConstants;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.WriteAction;
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils;
+
+/// calculate date/time on the fly to avoid setting an outdated value
+class UltrahumanSetTimeAction extends WriteAction {
+ UltrahumanSetTimeAction(BluetoothGattCharacteristic characteristic) {
+ super(characteristic, null);
+ }
+
+ @Override
+ protected boolean writeValue(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, byte[] value) {
+ final Calendar calendar = DateTimeUtils.getCalendarUTC();
+ final long millis = calendar.getTimeInMillis();
+ final long epoc = Math.round(millis / 1000.0d);
+
+ byte[] command = new byte[]{
+ UltrahumanConstants.OPERATION_SETTIME,
+ (byte) (epoc & 0xff),
+ (byte) ((epoc >> 8) & 0xff),
+ (byte) ((epoc >> 16) & 0xff),
+ (byte) ((epoc >> 24) & 0xff)
+ };
+ return super.writeValue(gatt, characteristic, command);
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 9ffb83d1aa..19862fe2b2 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1964,6 +1964,7 @@
Colmi R06
Colmi R09
Colmi R10
+ Ultrahuman Ring AIR
Bowers and Wilkins P series
EarFun Air S
EarFun Air Pro 4
@@ -3738,4 +3739,11 @@
Noise Control (Normal, Ambient Sound)
Noise Control (Wind Noise, Ambient Sound)
Redial
+ airplane mode activated
+ ring is charging - airplane mode NOT activated
+ ring is fully charged - airplane mode NOT activated
+ unknown error - airplane mode NOT activated
+ Activate airplane mode?
+ airplane mode
+ received unhandled response code 0x%x for operation 0x%x