Add experimental support for Ultrahuman Air smart ring (#4602)

This adds basic support for the Ultrahuman Air smart ring: https://www.ultrahuman.com/  (#4573)

Working:
- [x] pairing
- [x] fetching historic records
- [X] charting HR
- [x] charting blood oxygen
- [x] charting temperature
- [x] charting activity
- [x] charting steps
- [x] charting stress
- [x] activating air plane mode

Protocol documentation can be found in Freeyourgadget/website#86

HRV is fetched but not charted as the device has no HrvSummarySampleProvider.

Co-authored-by: Thomas Kuehne <thomas.kuehne@gmx.li>
Reviewed-on: https://codeberg.org/Freeyourgadget/Gadgetbridge/pulls/4602
Co-authored-by: ThomasKuehne <thomaskuehne@noreply.codeberg.org>
Co-committed-by: ThomasKuehne <thomaskuehne@noreply.codeberg.org>
This commit is contained in:
ThomasKuehne
2025-03-20 20:07:20 +00:00
committed by José Rebelo
co-authored by Thomas Kuehne
parent 329514d04d
commit a1cdfe8c89
16 changed files with 1521 additions and 14 deletions
@@ -46,6 +46,8 @@ public class GBDaoGenerator {
private static final String SAMPLE_HRV_BASELINE_BALANCED_UPPER = "baselineBalancedUpper"; 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_STATUS_NUM = "statusNum";
private static final String SAMPLE_HRV_VALUE = "value"; 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 = "temperature";
private static final String SAMPLE_TEMPERATURE_TYPE = "temperatureType"; private static final String SAMPLE_TEMPERATURE_TYPE = "temperatureType";
private static final String SAMPLE_WEIGHT_KG = "weightKg"; private static final String SAMPLE_WEIGHT_KG = "weightKg";
@@ -54,7 +56,7 @@ public class GBDaoGenerator {
public static void main(String[] args) throws Exception { 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 userAttributes = addUserAttributes(schema);
Entity user = addUserInfo(schema, userAttributes); Entity user = addUserInfo(schema, userAttributes);
@@ -157,6 +159,9 @@ public class GBDaoGenerator {
addHuaweiActivitySample(schema, user, device); addHuaweiActivitySample(schema, user, device);
addUltrahumanActivitySample(schema, user, device);
addUltrahumanDeviceStateSample(schema, user, device);
Entity huaweiWorkoutSummary = addHuaweiWorkoutSummarySample(schema, user, device); Entity huaweiWorkoutSummary = addHuaweiWorkoutSummarySample(schema, user, device);
addHuaweiWorkoutDataSample(schema, huaweiWorkoutSummary); addHuaweiWorkoutDataSample(schema, huaweiWorkoutSummary);
addHuaweiWorkoutPaceSample(schema, huaweiWorkoutSummary); addHuaweiWorkoutPaceSample(schema, huaweiWorkoutSummary);
@@ -181,6 +186,13 @@ public class GBDaoGenerator {
addActivitySummary(schema, user, device); addActivitySummary(schema, user, device);
addBatteryLevel(schema, 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"); new DaoGenerator().generateAll(schema, "app/src/main/java");
} }
@@ -328,7 +340,7 @@ public class GBDaoGenerator {
Entity stressSample = addEntity(schema, "HuamiStressSample"); Entity stressSample = addEntity(schema, "HuamiStressSample");
addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device); addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device);
stressSample.addIntProperty("typeNum").notNull().codeBeforeGetter(OVERRIDE); stressSample.addIntProperty("typeNum").notNull().codeBeforeGetter(OVERRIDE);
stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE); stressSample.addIntProperty(SAMPLE_STRESS).notNull().codeBeforeGetter(OVERRIDE);
return stressSample; return stressSample;
} }
@@ -336,7 +348,7 @@ public class GBDaoGenerator {
Entity spo2sample = addEntity(schema, "HuamiSpo2Sample"); Entity spo2sample = addEntity(schema, "HuamiSpo2Sample");
addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device); addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device);
spo2sample.addIntProperty("typeNum").notNull().codeBeforeGetter(OVERRIDE); spo2sample.addIntProperty("typeNum").notNull().codeBeforeGetter(OVERRIDE);
spo2sample.addIntProperty("spo2").notNull().codeBeforeGetter(OVERRIDE); spo2sample.addIntProperty(SAMPLE_SPO2).notNull().codeBeforeGetter(OVERRIDE);
return spo2sample; return spo2sample;
} }
@@ -400,8 +412,8 @@ public class GBDaoGenerator {
activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE); activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE); activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
addHeartRateProperties(activitySample); addHeartRateProperties(activitySample);
activitySample.addIntProperty("stress"); activitySample.addIntProperty(SAMPLE_STRESS);
activitySample.addIntProperty("spo2"); activitySample.addIntProperty(SAMPLE_SPO2);
return activitySample; return activitySample;
} }
@@ -486,14 +498,14 @@ public class GBDaoGenerator {
private static Entity addCmfStressSample(Schema schema, Entity user, Entity device) { private static Entity addCmfStressSample(Schema schema, Entity user, Entity device) {
Entity stressSample = addEntity(schema, "CmfStressSample"); Entity stressSample = addEntity(schema, "CmfStressSample");
addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device); addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device);
stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE); stressSample.addIntProperty(SAMPLE_STRESS).notNull().codeBeforeGetter(OVERRIDE);
return stressSample; return stressSample;
} }
private static Entity addCmfSpo2Sample(Schema schema, Entity user, Entity device) { private static Entity addCmfSpo2Sample(Schema schema, Entity user, Entity device) {
Entity spo2sample = addEntity(schema, "CmfSpo2Sample"); Entity spo2sample = addEntity(schema, "CmfSpo2Sample");
addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device); addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device);
spo2sample.addIntProperty("spo2").notNull().codeBeforeGetter(OVERRIDE); spo2sample.addIntProperty(SAMPLE_SPO2).notNull().codeBeforeGetter(OVERRIDE);
return spo2sample; return spo2sample;
} }
@@ -551,14 +563,14 @@ public class GBDaoGenerator {
private static Entity addColmiStressSample(Schema schema, Entity user, Entity device) { private static Entity addColmiStressSample(Schema schema, Entity user, Entity device) {
Entity stressSample = addEntity(schema, "ColmiStressSample"); Entity stressSample = addEntity(schema, "ColmiStressSample");
addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device); addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device);
stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE); stressSample.addIntProperty(SAMPLE_STRESS).notNull().codeBeforeGetter(OVERRIDE);
return stressSample; return stressSample;
} }
private static Entity addColmiSpo2Sample(Schema schema, Entity user, Entity device) { private static Entity addColmiSpo2Sample(Schema schema, Entity user, Entity device) {
Entity spo2sample = addEntity(schema, "ColmiSpo2Sample"); Entity spo2sample = addEntity(schema, "ColmiSpo2Sample");
addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device); addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device);
spo2sample.addIntProperty("spo2").notNull().codeBeforeGetter(OVERRIDE); spo2sample.addIntProperty(SAMPLE_SPO2).notNull().codeBeforeGetter(OVERRIDE);
return spo2sample; return spo2sample;
} }
@@ -759,7 +771,7 @@ public class GBDaoGenerator {
private static Entity addHybridHRSpo2Sample(Schema schema, Entity user, Entity device) { private static Entity addHybridHRSpo2Sample(Schema schema, Entity user, Entity device) {
Entity spo2sample = addEntity(schema, "HybridHRSpo2Sample"); Entity spo2sample = addEntity(schema, "HybridHRSpo2Sample");
addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device); addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device);
spo2sample.addIntProperty("spo2").notNull().codeBeforeGetter(OVERRIDE); spo2sample.addIntProperty(SAMPLE_SPO2).notNull().codeBeforeGetter(OVERRIDE);
return spo2sample; return spo2sample;
} }
@@ -834,7 +846,7 @@ public class GBDaoGenerator {
private static Entity addGarminStressSample(Schema schema, Entity user, Entity device) { private static Entity addGarminStressSample(Schema schema, Entity user, Entity device) {
Entity stressSample = addEntity(schema, "GarminStressSample"); Entity stressSample = addEntity(schema, "GarminStressSample");
addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device); addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device);
stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE); stressSample.addIntProperty(SAMPLE_STRESS).notNull().codeBeforeGetter(OVERRIDE);
return stressSample; return stressSample;
} }
@@ -848,7 +860,7 @@ public class GBDaoGenerator {
private static Entity addGarminSpo2Sample(Schema schema, Entity user, Entity device) { private static Entity addGarminSpo2Sample(Schema schema, Entity user, Entity device) {
Entity spo2sample = addEntity(schema, "GarminSpo2Sample"); Entity spo2sample = addEntity(schema, "GarminSpo2Sample");
addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device); addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device);
spo2sample.addIntProperty("spo2").notNull().codeBeforeGetter(OVERRIDE); spo2sample.addIntProperty(SAMPLE_SPO2).notNull().codeBeforeGetter(OVERRIDE);
return spo2sample; return spo2sample;
} }
@@ -1337,7 +1349,7 @@ public class GBDaoGenerator {
Entity stressSample = addEntity(schema, "Wena3StressSample"); Entity stressSample = addEntity(schema, "Wena3StressSample");
addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device); addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device);
stressSample.addIntProperty("typeNum").notNull().codeBeforeGetter(OVERRIDE); stressSample.addIntProperty("typeNum").notNull().codeBeforeGetter(OVERRIDE);
stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE); stressSample.addIntProperty(SAMPLE_STRESS).notNull().codeBeforeGetter(OVERRIDE);
return stressSample; return stressSample;
} }
@@ -1613,6 +1625,31 @@ public class GBDaoGenerator {
return workoutSectionsSample; 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) { private static Entity addHuaweiDictData(Schema schema, Entity user, Entity device) {
Entity dictData = addEntity(schema, "HuaweiDictData"); Entity dictData = addEntity(schema, "HuaweiDictData");
@@ -1685,4 +1722,40 @@ public class GBDaoGenerator {
sample.addFloatProperty(SAMPLE_WEIGHT_KG).notNull().codeBeforeGetter(OVERRIDE); sample.addFloatProperty(SAMPLE_WEIGHT_KG).notNull().codeBeforeGetter(OVERRIDE);
return sample; 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;
}
} }
@@ -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 <https://www.gnu.org/licenses/>. */
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<GenericHeartRateSample> {
public GenericHeartRateSampleProvider(final GBDevice device, final DaoSession session) {
super(device, session);
}
@NonNull
@Override
public AbstractDao<GenericHeartRateSample, ?> 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();
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<GenericHrvValueSample> {
public GenericHrvValueSampleProvider(final GBDevice device, final DaoSession session) {
super(device, session);
}
@NonNull
@Override
public AbstractDao<GenericHrvValueSample, ?> 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();
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<GenericSpo2Sample> {
public GenericSpo2SampleProvider(final GBDevice device, final DaoSession session) {
super(device, session);
}
@NonNull
@Override
public AbstractDao<GenericSpo2Sample, ?> 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();
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<GenericStressSample> {
public GenericStressSampleProvider(final GBDevice device, final DaoSession session) {
super(device, session);
}
@NonNull
@Override
public AbstractDao<GenericStressSample, ?> 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();
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<GenericTemperatureSample> {
public GenericTemperatureSampleProvider(final GBDevice device, final DaoSession session) {
super(device, session);
}
@NonNull
@Override
public AbstractDao<GenericTemperatureSample, ?> 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();
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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;
}
@@ -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 <https://www.gnu.org/licenses/>. */
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();
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<DeviceCardAction> 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<AbstractDao<?, ?>, Property> daoMap = new HashMap<AbstractDao<?, ?>, 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<AbstractDao<?, ?>, 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;
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<UltrahumanActivitySample> {
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<UltrahumanActivitySample, ?> 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();
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<UltrahumanDeviceStateSample> {
public UltrahumanDeviceStateSampleProvider(GBDevice device, DaoSession session) {
super(device, session);
}
@NonNull
@Override
public AbstractDao<UltrahumanDeviceStateSample, ?> 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();
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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;
}
}
@@ -284,6 +284,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.soundcore.q30.SoundcoreQ30Co
import nodomain.freeyourgadget.gadgetbridge.devices.supercars.SuperCarsCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.supercars.SuperCarsCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.test.TestDeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.test.TestDeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.tlw64.TLW64Coordinator; 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.um25.Coordinator.UM25Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.vesc.VescCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.vesc.VescCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.vibratissimo.VibratissimoCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.vibratissimo.VibratissimoCoordinator;
@@ -625,7 +626,8 @@ public enum DeviceType {
CYCLING_SENSOR(CyclingSensorCoordinator.class), CYCLING_SENSOR(CyclingSensorCoordinator.class),
BLE_GATT_CLIENT(BleGattClientCoordinator.class), BLE_GATT_CLIENT(BleGattClientCoordinator.class),
MARSTEK_B2500(MarstekB2500DeviceCoordinator.class), MARSTEK_B2500(MarstekB2500DeviceCoordinator.class),
TEST(TestDeviceCoordinator.class); TEST(TestDeviceCoordinator.class),
ULTRAHUMAN_RING_AIR(UltrahumanDeviceCoordinator.class);
private DeviceCoordinator coordinator; private DeviceCoordinator coordinator;
@@ -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 <https://www.gnu.org/licenses/>. */
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<UltrahumanDeviceSupport> 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));
}
}
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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);
}
}
+8
View File
@@ -1964,6 +1964,7 @@
<string name="devicetype_colmi_r06">Colmi R06</string> <string name="devicetype_colmi_r06">Colmi R06</string>
<string name="devicetype_colmi_r09">Colmi R09</string> <string name="devicetype_colmi_r09">Colmi R09</string>
<string name="devicetype_colmi_r10">Colmi R10</string> <string name="devicetype_colmi_r10">Colmi R10</string>
<string name="devicetype_ultrahuma_ring_air">Ultrahuman Ring AIR</string>
<string name="devicetype_bandw_pseries">Bowers and Wilkins P series</string> <string name="devicetype_bandw_pseries">Bowers and Wilkins P series</string>
<string name="devicetype_earfun_air_s">EarFun Air S</string> <string name="devicetype_earfun_air_s">EarFun Air S</string>
<string name="devicetype_earfun_air_pro_4">EarFun Air Pro 4</string> <string name="devicetype_earfun_air_pro_4">EarFun Air Pro 4</string>
@@ -3738,4 +3739,11 @@
<string name="earfun_tap_action_noise_control_normal_ambient">Noise Control (Normal, Ambient Sound)</string> <string name="earfun_tap_action_noise_control_normal_ambient">Noise Control (Normal, Ambient Sound)</string>
<string name="earfun_tap_action_noise_control_wind_ambient">Noise Control (Wind Noise, Ambient Sound)</string> <string name="earfun_tap_action_noise_control_wind_ambient">Noise Control (Wind Noise, Ambient Sound)</string>
<string name="earfun_tap_action_redial">Redial</string> <string name="earfun_tap_action_redial">Redial</string>
<string name="ultrahuman_airplane_mode_activated">airplane mode activated</string>
<string name="ultrahuman_airplane_mode_on_charger">ring is charging - airplane mode NOT activated</string>
<string name="ultrahuman_airplane_mode_too_full">ring is fully charged - airplane mode NOT activated</string>
<string name="ultrahuman_airplane_mode_unknown">unknown error - airplane mode NOT activated</string>
<string name="ultrahuman_airplane_mode_question">Activate airplane mode?</string>
<string name="ultrahuman_airplane_mode_title">airplane mode</string>
<string name="ultrahuman_unhandled_error_response">received unhandled response code 0x%x for operation 0x%x</string>
</resources> </resources>