diff --git a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java
index 077aba697a..9feaffc8f2 100644
--- a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java
+++ b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java
@@ -75,6 +75,7 @@ public class GBDaoGenerator {
Entity userDefinedActivityOverlay = addActivityDescription(schema, tag, user);
addMakibesHR3ActivitySample(schema, user, device);
+ addOVTouch26ActivitySample(schema, user, device);
addMiBandActivitySample(schema, user, device);
addHuamiExtendedActivitySample(schema, user, device);
addHuamiStressSample(schema, user, device);
@@ -340,6 +341,19 @@ public class GBDaoGenerator {
return activitySample;
}
+ private static Entity addOVTouch26ActivitySample(Schema schema, Entity user, Entity device) {
+ Entity activitySample = addEntity(schema, "OVTouch26ActivitySample");
+ activitySample.implementsSerializable();
+ addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device);
+ activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ activitySample.addIntProperty(SAMPLE_BLOOD_PRESSURE_SYSTOLIC).notNull();
+ activitySample.addIntProperty(SAMPLE_BLOOD_PRESSURE_DIASTOLIC).notNull();
+ activitySample.addIntProperty("sleep");
+ activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
+ addHeartRateProperties(activitySample);
+ return activitySample;
+ }
+
private static Entity addMiBandActivitySample(Schema schema, Entity user, Entity device) {
Entity activitySample = addEntity(schema, "MiBandActivitySample");
activitySample.implementsSerializable();
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/overmax/OVTouch26Constants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/overmax/OVTouch26Constants.java
new file mode 100644
index 0000000000..fc886b842d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/overmax/OVTouch26Constants.java
@@ -0,0 +1,125 @@
+/* Copyright (C) 2019-2025 vappster
+
+ 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.overmax;
+
+import java.util.UUID;
+
+public final class OVTouch26Constants {
+
+ public static final UUID UUID_SERVICE = UUID.fromString("6e400001-b5a3-f393-e0a9-e50e24dcca9e");
+ public static final UUID UUID_CHARACTERISTIC_CONTROL = UUID.fromString("6e400002-b5a3-f393-e0a9-e50e24dcca9e");
+ public static final UUID UUID_CHARACTERISTIC_REPORT = UUID.fromString("6e400003-b5a3-f393-e0a9-e50e24dcca9e");
+
+ //Packet structure (commands)
+ //ab 00 (payload length) (payload crc) (packet seq#) (unknown) 00 (cmd) 00 [args]
+ // ^--------- payload --------^
+ //payload len, payload crc and packet seq# are uint16, unknown and cmd are uint8
+ //checksum is crc16/arc and is calculated starting from the (unknown) variable until the end
+ //of the packet
+ //packet seq# is a counter that increments itself for every packet sent to the device
+ //NOTE:
+ //Based on the known possible values, the unknown variable here seems to at least control which
+ //ack id to use for the command's associated ACK request (see below). However, there may be more
+ //to this variable than just that (for example, why do two completely unrelated commands like
+ //CMD_FIND_DEVICE and CMD_NOTIFICATION use the same value?) so I'm not *fully* sure of this yet
+
+ public static final byte[] PACKET_CMD_TEMPLATE = {
+ (byte) 0xab,
+ (byte) 0x00,
+ (byte) 0, //length
+ (byte) 0,
+ (byte) 0, //crc16 arc
+ (byte) 0,
+ (byte) 0, //packet seq
+ (byte) 0x01,
+ (byte) 0, //unknown (see note above)
+ (byte) 0x00,
+ (byte) 0, //cmd
+ (byte) 0x00
+// ,arguments
+ };
+
+ //Packet structure (ACK reply)
+ //ab 10 00 00 00 00 (msg id)
+ //
+ //msg id is uint16 and is given to the client via an ACK request with the following structure:
+ //ab 00 00 (ack id) (unknown) (msg id)
+ //ack id is uint8 and it seems to be a value used to differentiate which cmd requested the ack
+ //unknown is uint16 (doesn't seem to be crc)
+ public static final byte[] PACKET_ACK_TEMPLATE = {
+ (byte) 0xab,
+ (byte) 0x10,
+ (byte) 0x00,
+ (byte) 0x00,
+ (byte) 0x00,
+ (byte) 0x00,
+ (byte) 0, //msg id
+ (byte) 0
+ };
+
+ public static final byte[] ARGS_NONE = {
+ (byte) 0x00
+ };
+
+ public static final byte[] ARGS_FIND_DEVICE = {
+ (byte) 0x01,
+ (byte) 0x01
+ };
+
+ public static final byte[] CMD_FIND_DEVICE = new byte[]{(byte) 0x04, 0x61};
+ public static final byte[] CMD_GET_STEPS = new byte[]{(byte) 0x05, 0x41};
+ public static final byte[] CMD_GET_HEART_RATE = new byte[]{(byte) 0x05, 0x43};
+ public static final byte[] CMD_BATTERY_INFO = new byte[]{(byte) 0x02, 0x08};
+ public static final byte[] CMD_NOTIFICATION = new byte[]{(byte) 0x04, 0x52};
+
+ //Known reply values
+ public static final int REPLY_HEADER_BATTERY = 0x02000900;
+ public static final int REPLY_HEADER_STEPS = 0x05004200;
+ public static final int REPLY_HEADER_HEART_RATE = 0x05004400;
+ public static final int REPLY_HEADER_FIND_MY_PHONE = 0x04006000;
+ public static final int REPLY_HEADER_ERROR = 0xe6000000;
+ public static final int REPLY_HEADER_ACK = 0xab0000;
+
+ /* A note about pairing and bonding
+
+ When pairing the watch in its proprietary app, the following command & args sent to the watch:
+ ------------------------------------------------------------------------------------------------
+ public static final byte[] ARGS_BIND = {
+ (byte) 0x20, //arg length, 1-based (excludes itself from length), always 0x20
+ (byte) 0xff //always 0xff
+ //The next 31 bytes appear to all always be 0x00
+ };
+
+ public static final byte[] CMD_BIND = new byte[]{(byte) 0x03, 0x01};
+ ------------------------------------------------------------------------------------------------
+ The command will trigger a screen on the device in which the user can choose whether to pair the
+ device to the phone. Once the user makes their decision, their choice will get reported to the
+ app, which will then proceed accordingly.
+
+ However, on the watch's side, the command's only purpose is exactly that: display the screen and
+ return the resulting boolean choice. No other info about either device is sent at any point nor
+ any further action is taken on the watch's side. The watch will otherwise allow anyone to
+ connect, bond and send/receive commands regardless of whether this screen has been shown before
+ (in fact, all commands work even if the device is connected but not bound!). The pairing and
+ bonding process is handled entirely by the app via standard BTLE procedures.
+
+ Therefore, the command is not actually needed for the binding process and it only is an extra
+ arbitrary step intended specifically for the proprietary app. Due to this, it can be skipped
+ entirely and, as Gadgetbridge can effectively give the user additional choices compared to the
+ proprietary app via its BONDING_STYLE_ASK setting (for example whether to bond and to add the
+ device as a companion) this command is unused in the current implementation. */
+}
\ No newline at end of file
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/overmax/OVTouch26Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/overmax/OVTouch26Coordinator.java
new file mode 100644
index 0000000000..fddcb2eb16
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/overmax/OVTouch26Coordinator.java
@@ -0,0 +1,134 @@
+/* Copyright (C) 2019-2025 vappster
+
+ 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.overmax;
+
+import androidx.annotation.NonNull;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import de.greenrobot.dao.query.QueryBuilder;
+import nodomain.freeyourgadget.gadgetbridge.GBException;
+import nodomain.freeyourgadget.gadgetbridge.R;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.BangleJSActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.CasioGBX100ActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.OVTouch26ActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
+import nodomain.freeyourgadget.gadgetbridge.service.devices.overmax.OVTouch26DeviceSupport;
+
+public class OVTouch26Coordinator extends AbstractBLEDeviceCoordinator {
+ @Override
+ public int getDeviceNameResource() {
+ return R.string.devicetype_ovtouch_26;
+ }
+
+ @Override
+ public int getDefaultIconResource() {
+ return R.drawable.ic_device_miwatch;
+ }
+
+ @Override
+ public String getManufacturer() {
+ return "Overmax";
+ }
+
+ @NonNull
+ @Override
+ public Class extends DeviceSupport> getDeviceSupportClass(final GBDevice device) {
+ return OVTouch26DeviceSupport.class;
+ }
+
+ @Override
+ protected Pattern getSupportedDeviceName() {
+ return Pattern.compile("OV-Touch2.6_LE");
+ }
+
+ @Override
+ public boolean supportsRealtimeData(@NonNull GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsFindDevice(@NonNull GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityTracking(@NonNull GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsHeartRateMeasurement(GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public boolean supportsActivityDataFetching(final GBDevice device) {
+ return true;
+ }
+
+ @Override
+ public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
+ return new int[]{
+ R.xml.devicesettings_timeformat,
+ R.xml.devicesettings_liftwrist_display,
+ R.xml.devicesettings_disconnectnotification,
+ R.xml.devicesettings_donotdisturb_no_auto,
+ R.xml.devicesettings_find_phone,
+ R.xml.devicesettings_transliteration
+ };
+ }
+
+ @Override
+ public boolean supportsWeather(GBDevice device) {
+ return false;
+ }
+
+ @Override
+ public DeviceCoordinator.DeviceKind getDeviceKind(@NonNull GBDevice device) {
+ return DeviceCoordinator.DeviceKind.FITNESS_BAND;
+ }
+
+ @Override
+ public SampleProvider extends ActivitySample> getSampleProvider(GBDevice device, DaoSession session) {
+ return new OVTouch26SampleProvider(device, session);
+ }
+
+ @Override
+ public boolean addBatteryPollingSettings() {
+ return true;
+ }
+
+ @Override
+ public Map, Property> getAllDeviceDao(@NonNull final DaoSession session) {
+ Map, Property> map = new HashMap<>(1);
+ map.put(session.getOVTouch26ActivitySampleDao(), OVTouch26ActivitySampleDao.Properties.DeviceId);
+ return map;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/overmax/OVTouch26SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/overmax/OVTouch26SampleProvider.java
new file mode 100644
index 0000000000..3b0d47e285
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/overmax/OVTouch26SampleProvider.java
@@ -0,0 +1,118 @@
+/* Copyright (C) 2019-2025 vappster, José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.devices.overmax;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+
+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.OVTouch26ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.OVTouch26ActivitySampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
+
+public class OVTouch26SampleProvider extends AbstractSampleProvider {
+ public static final int TYPE_ACTIVITY = 1;
+ private static final Logger LOG = LoggerFactory.getLogger(OVTouch26SampleProvider.class);
+
+ public OVTouch26SampleProvider(GBDevice device, DaoSession session) {
+ super(device, session);
+ }
+
+ @Override
+ public ActivityKind normalizeType(int rawType) {
+ return ActivityKind.fromCode(rawType);
+ }
+
+ @Override
+ public int toRawActivityKind(ActivityKind activityKind) {
+ return activityKind.getCode();
+ }
+
+ @Override
+ public float normalizeIntensity(int rawIntensity) {
+ return rawIntensity / 255.0f;
+ }
+
+ @Override
+ public OVTouch26ActivitySample createActivitySample() {
+ return new OVTouch26ActivitySample();
+ }
+
+ @Override
+ public AbstractDao getSampleDao() {
+ return getSession().getOVTouch26ActivitySampleDao();
+ }
+
+ @Nullable
+ @Override
+ protected Property getRawKindSampleProperty() {
+ return OVTouch26ActivitySampleDao.Properties.RawKind;
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return OVTouch26ActivitySampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return OVTouch26ActivitySampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ protected List getGBActivitySamples(final int timestamp_from, final int timestamp_to) {
+ //Code from GarminActivitySampleProvider, thank you José Rebelo!
+ LOG.trace(
+ "Getting OVTouch26 activity samples between {} and {}",
+ timestamp_from,
+ timestamp_to
+ );
+
+ final long nanoStart = System.nanoTime();
+
+ final List samples = fillGaps(
+ super.getGBActivitySamples(timestamp_from + 60, timestamp_to + 60),
+ timestamp_from + 60,
+ timestamp_to + 60
+ );
+
+ samples.forEach(s -> s.setTimestamp(s.getTimestamp() - 60));
+
+ if (!samples.isEmpty()) {
+ convertCumulativeSteps(samples, OVTouch26ActivitySampleDao.Properties.Steps);
+ }
+
+ final long nanoEnd = System.nanoTime();
+
+ final long executionTime = (nanoEnd - nanoStart) / 1000000;
+
+ LOG.trace("Getting OVTouch26 samples took {}ms", executionTime);
+
+ return samples;
+ }
+}
\ No newline at end of file
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 d9488a348c..e00e0c6179 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java
@@ -334,6 +334,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.onemoresonoflow.OneMoreSonoF
import nodomain.freeyourgadget.gadgetbridge.devices.oppo.OppoEncoAir2Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.oppo.OppoEncoAirCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.oppo.OppoEncoBuds2Coordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.overmax.OVTouch26Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.pebble.PebbleCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.pinetime.PineTimeJFCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.pixel.PixelBudsACoordinator;
@@ -569,6 +570,7 @@ public enum DeviceType {
MICOMPOSITIONSCALE(MiCompositionScaleCoordinator.class),
BFH16(BFH16DeviceCoordinator.class),
MAKIBESHR3(MakibesHR3Coordinator.class),
+ OVTOUCH26(OVTouch26Coordinator.class),
BANGLEJS(BangleJSCoordinator.class),
FOSSILQHYBRID(QHybridCoordinator.class),
TLW64(TLW64Coordinator.class),
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/overmax/OVTouch26DeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/overmax/OVTouch26DeviceSupport.java
new file mode 100644
index 0000000000..56566a2ccb
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/overmax/OVTouch26DeviceSupport.java
@@ -0,0 +1,413 @@
+/* Copyright (C) 2019-2025 Andreas Shimokawa, Arjan Schrijver, Cre3per,
+ Damien Gaignon, Taavi Eomäe, vappster
+
+ 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 . */
+//TODO: Implement SpO2, sleep and sports data sync, as well as various misc commands (for ex.
+//smart camera, automatic date & time sync, etc.)
+//TODO: Various settings (device wake gesture, alarms, etc.)
+//TODO: Firmware updates
+
+package nodomain.freeyourgadget.gadgetbridge.service.devices.overmax;
+
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.Handler;
+import android.os.Looper;
+import android.widget.Toast;
+
+import androidx.localbroadcastmanager.content.LocalBroadcastManager;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Arrays;
+
+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.GBDeviceEventFindPhone;
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo;
+import nodomain.freeyourgadget.gadgetbridge.devices.overmax.OVTouch26Constants;
+import nodomain.freeyourgadget.gadgetbridge.devices.overmax.OVTouch26SampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.Device;
+import nodomain.freeyourgadget.gadgetbridge.entities.OVTouch26ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.entities.User;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryState;
+import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLESingleDeviceSupport;
+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.profiles.IntentListener;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.deviceinfo.DeviceInfo;
+import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.deviceinfo.DeviceInfoProfile;
+import nodomain.freeyourgadget.gadgetbridge.util.CheckSums;
+import nodomain.freeyourgadget.gadgetbridge.util.GB;
+
+public class OVTouch26DeviceSupport extends AbstractBTLESingleDeviceSupport {
+ private static final Logger LOG = LoggerFactory.getLogger(OVTouch26DeviceSupport.class);
+ private final DeviceInfoProfile deviceInfoProfile;
+ int packet_seq = 0x0000;
+ private BluetoothGattCharacteristic mControlCharacteristic = null;
+ private BluetoothGattCharacteristic mReportCharacteristic = null;
+ private final Handler handler = new Handler(Looper.getMainLooper());
+ private final Runnable batteryRunner = () -> {
+ LOG.info("Running retrieving battery through runner.");
+ sendCmd(OVTouch26Constants.CMD_BATTERY_INFO, OVTouch26Constants.ARGS_NONE, "getBatteryInfo");
+ };
+ private OVTouch26ActivitySample CurrentSample = null;
+
+ public OVTouch26DeviceSupport() {
+ super(LOG);
+
+ addSupportedService(OVTouch26Constants.UUID_SERVICE);
+ addSupportedService(GattService.UUID_SERVICE_DEVICE_INFORMATION);
+
+ IntentListener mListener = new 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));
+ }
+ }
+ };
+
+ deviceInfoProfile = new DeviceInfoProfile<>(this);
+ deviceInfoProfile.addListener(mListener);
+ addSupportedProfile(deviceInfoProfile);
+ }
+
+ @Override
+ public boolean useAutoConnect() {
+ return false;
+ }
+
+ private byte[] craftData(byte[] command, byte[] data) {
+ byte[] payload = new byte[data.length + 4];
+ byte[] result = new byte[OVTouch26Constants.PACKET_CMD_TEMPLATE.length + data.length];
+ int crc;
+
+ System.arraycopy(OVTouch26Constants.PACKET_CMD_TEMPLATE, 0, result, 0, OVTouch26Constants.PACKET_CMD_TEMPLATE.length);
+
+ //The unknown and command bytes are part of the checksum-protected data
+ result[8] = command[0];
+ result[10] = command[1];
+
+ System.arraycopy(data, 0, result, OVTouch26Constants.PACKET_CMD_TEMPLATE.length, data.length);
+ System.arraycopy(result, 8, payload, 0, payload.length);
+ crc = CheckSums.getCRC16ansi(payload, 0x0000);
+
+ //All of these are not checksum-protected
+ result[2] = (byte) ((data.length + 4 >> 8) & 0xFF);
+ result[3] = (byte) (data.length + 4 & 0xFF);
+ result[4] = (byte) ((crc >> 8) & 0xFF);
+ result[5] = (byte) (crc & 0xFF);
+ result[6] = (byte) ((packet_seq >> 8) & 0xFF);
+ result[7] = (byte) (packet_seq & 0xFF);
+
+ if (packet_seq == 0xFFFF) {
+ packet_seq = 0;
+ } else {
+ packet_seq++;
+ }
+
+ return result;
+ }
+
+ private OVTouch26ActivitySample createActivitySample() {
+ OVTouch26ActivitySample sample = new OVTouch26ActivitySample();
+
+ sample.setTimestamp(-1);
+ sample.setRawIntensity(ActivitySample.NOT_MEASURED);
+ sample.setRawKind(OVTouch26SampleProvider.TYPE_ACTIVITY);
+ sample.setSteps(ActivitySample.NOT_MEASURED);
+ sample.setHeartRate(ActivitySample.NOT_MEASURED);
+
+ return sample;
+ }
+
+ private void sendTransaction(byte[] data, String transactionName) {
+ TransactionBuilder transactionBuilder = this.createTransactionBuilder(transactionName);
+
+ transactionBuilder.write(this.mControlCharacteristic, data);
+ try {
+ transactionBuilder.queueConnected();
+ } catch (Exception ex) {
+ LOG.warn(ex.getMessage());
+ }
+ }
+
+ private void sendCmd(byte[] cmd, byte[] args, String transactionName) {
+ byte[] data = this.craftData(cmd, args);
+
+ sendTransaction(data, transactionName);
+ }
+
+ private void sendAck(byte[] packet) {
+ byte[] ackPacket = OVTouch26Constants.PACKET_ACK_TEMPLATE;
+ ackPacket[6] = packet[packet.length - 2];
+ ackPacket[7] = packet[packet.length - 1];
+
+ sendTransaction(ackPacket, "sendAckPacket");
+ }
+
+ @Override
+ public void onFetchRecordedData(int dataTypes) {
+ CurrentSample = createActivitySample();
+ sendCmd(OVTouch26Constants.CMD_GET_STEPS, OVTouch26Constants.ARGS_NONE, "sendGetStepsCmd");
+ }
+
+ @Override
+ public void onFindDevice(boolean start) {
+ if (!start) {
+ return;
+ }
+
+ sendCmd(OVTouch26Constants.CMD_FIND_DEVICE, OVTouch26Constants.ARGS_FIND_DEVICE, "findDevice");
+ }
+
+ public boolean startBatteryRunnerDelayed() {
+ int interval_minutes = GBApplication.getDevicePrefs(gbDevice).getBatteryPollingIntervalMinutes();
+ int interval = interval_minutes * 60 * 1000;
+ LOG.debug("Starting battery runner delayed by {} ({} minutes)", interval, interval_minutes);
+ handler.removeCallbacks(batteryRunner);
+ return handler.postDelayed(batteryRunner, interval);
+ }
+
+ public void stopBatteryRunnerDelayed() {
+ LOG.debug("Stopping battery runner delayed");
+ handler.removeCallbacks(batteryRunner);
+ }
+
+ @Override
+ protected TransactionBuilder initializeDevice(TransactionBuilder builder) {
+ packet_seq = 0x0000;
+ GB.updateTransferNotification(null, getContext().getString(R.string.busy_task_fetch_activity_data), true, 0, getContext());
+
+ builder.setDeviceState(GBDevice.State.INITIALIZING);
+ deviceInfoProfile.requestDeviceInfo(builder);
+
+ this.mControlCharacteristic = getCharacteristic(OVTouch26Constants.UUID_CHARACTERISTIC_CONTROL);
+ this.mReportCharacteristic = getCharacteristic(OVTouch26Constants.UUID_CHARACTERISTIC_REPORT);
+
+ builder.notify(this.mReportCharacteristic, true);
+ builder.setCallback(this);
+
+ builder.setDeviceState(GBDevice.State.INITIALIZED);
+
+ //Send battery info command if polling is enabled to show the charge % in the devices screen as soon as we're connected
+ //(It's too early to use the sendCmd func so we use the following instead)
+ if (GBApplication.getDevicePrefs(getDevice()).getBatteryPollingEnabled()) {
+ builder.write(this.mControlCharacteristic, this.craftData(OVTouch26Constants.CMD_BATTERY_INFO, OVTouch26Constants.ARGS_NONE));
+ }
+
+ GB.updateTransferNotification(null, "", false, 100, getContext());
+
+ return builder;
+ }
+
+ private void handleDeviceInfo(DeviceInfo info) {
+ LOG.debug("Device info: {}", info);
+
+ GBDeviceEventVersionInfo versionCmd = new GBDeviceEventVersionInfo();
+ versionCmd.hwVersion = info.getHardwareRevision();
+ versionCmd.fwVersion = info.getFirmwareRevision();
+ handleGBDeviceEvent(versionCmd);
+ }
+
+ private void parseAndSetSampleTimestamp(byte[] packet, int offset) {
+ //The sample timestamps reported by this watch report how many seconds have passed since 01/01/2000 at 00:00.
+ //Therefore an easy way to convert them in a timestamp understandable by Gadgetbridge is to convert them to
+ //Unix time by adding 946681200 seconds (= the difference to Unix epoch)
+ if (CurrentSample.getTimestamp() == -1) { //If the timestamp has already been set by a previous cmd reply, don't overwrite it
+ int timestamp = BLETypeConversions.toUint32(packet[packet.length - offset], packet[packet.length - (offset + 1)], packet[packet.length - (offset + 2)], packet[packet.length - (offset + 3)]) + 946681200;
+ CurrentSample.setTimestamp(timestamp);
+ }
+ }
+
+ private void addCurrentSample() {
+ if (CurrentSample.getTimestamp() != -1) { //If timestamp is -1 it means no data has been recorded and the current sample can be discarded
+ try (DBHandler handler = GBApplication.acquireDB()) {
+ DaoSession session = handler.getDaoSession();
+
+ OVTouch26SampleProvider provider = new OVTouch26SampleProvider(gbDevice, session);
+ Device device = DBHelper.getDevice(getDevice(), handler.getDaoSession());
+ User user = DBHelper.getUser(handler.getDaoSession());
+ CurrentSample.setDevice(device);
+ CurrentSample.setUser(user);
+ CurrentSample.setProvider(provider);
+ provider.addGBActivitySample(CurrentSample);
+ Intent intent = new Intent(DeviceService.ACTION_REALTIME_SAMPLES)
+ .putExtra(GBDevice.EXTRA_DEVICE, getDevice())
+ .putExtra(DeviceService.EXTRA_REALTIME_SAMPLE, CurrentSample)
+ .putExtra(DeviceService.EXTRA_TIMESTAMP, CurrentSample.getTimestamp());
+ LocalBroadcastManager.getInstance(getContext()).sendBroadcast(intent);
+ } catch (Exception e) {
+ LOG.error("Error acquiring database", e);
+ }
+ }
+ CurrentSample = null;
+ GB.updateTransferNotification(null, "", false, 100, getContext()); //Clear notification
+ GB.signalActivityDataFinish(getDevice());
+ }
+
+ @Override
+ public boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, byte[] value) {
+ //The sample requests in the proprietary app always happens in this sequence:
+ //Steps -> Heart Rate -> (SpO2) -> (Sleep) -> (Sports data)
+ //We try to stick to that order here
+ switch (BLETypeConversions.toUint32(value[3], value[2], value[1], value[0])) { //Reply packet header, little endian
+ case OVTouch26Constants.REPLY_HEADER_BATTERY:
+ GBDeviceEventBatteryInfo batteryInfo = new GBDeviceEventBatteryInfo();
+
+ batteryInfo.level = value[5];
+ batteryInfo.state = BatteryState.UNKNOWN;
+
+ this.handleGBDeviceEvent(batteryInfo);
+ //Restart the battery runner once the packet has been handled
+ //(no need to check if battery polling is enabled as we only ever request battery info if it's enabled to begin with)
+ startBatteryRunnerDelayed();
+ break;
+ case OVTouch26Constants.REPLY_HEADER_STEPS:
+ //Sometimes when the device errors out it can return a much longer packet with what looks to be garbage data. In that case, we can just ignore it
+ //TODO: On the other hand, in *very* rare cases (for ex. multiple dropped packets in the span of 2+ days) the packet can include two samples with
+ //multiple timestamps (so far it seems like two at a time is the limit), add logic to parse those as well
+ if (CurrentSample != null && value.length == 13) {
+ int steps = BLETypeConversions.toUint32(value[value.length - 1], value[value.length - 2], value[value.length - 3], value[value.length - 4]);
+ parseAndSetSampleTimestamp(value, 5);
+ CurrentSample.setSteps(steps);
+ sendCmd(OVTouch26Constants.CMD_GET_HEART_RATE, OVTouch26Constants.ARGS_NONE, "getHeartRate");
+ }
+ break;
+ case OVTouch26Constants.REPLY_HEADER_HEART_RATE:
+ if (CurrentSample != null) {
+ if (value.length >= 10){
+ parseAndSetSampleTimestamp(value, 2);
+ CurrentSample.setHeartRate(value[value.length - 1]);
+ }
+ addCurrentSample(); //Always try to add the sample, even if heart rate was not reported
+ }
+ break;
+ case OVTouch26Constants.REPLY_HEADER_FIND_MY_PHONE:
+ GBDeviceEventFindPhone findPhoneEvent = new GBDeviceEventFindPhone();
+ if (value[5] == 0x01) {
+ findPhoneEvent.event = GBDeviceEventFindPhone.Event.START;
+ } else {
+ findPhoneEvent.event = GBDeviceEventFindPhone.Event.STOP;
+ }
+ evaluateGBDeviceEvent(findPhoneEvent);
+ break;
+ case OVTouch26Constants.REPLY_HEADER_ERROR: //If the watch returns an error the sent data is likely garbage/incorrect
+ LOG.debug("device reported error, invalidating sample!");
+ CurrentSample = null;
+ break;
+ default:
+ if (BLETypeConversions.toUint24(value[2], value[1], value[0]) == OVTouch26Constants.REPLY_HEADER_ACK) {
+ //Technically speaking, most packets seem to have their own distinct ACK requests identifiable by an unique
+ //ack id in the header (uint8, offset 0x3). Practically, they all follow the same request/response structure,
+ //and failing to reply to enough of them *will* eventually make the watch return an error
+ //Therefore, it's better to always reply to ACKs requests, regardless if the associated command is currently
+ //implemented or otherwise
+ sendAck(value);
+ }
+ break;
+ }
+
+ return false;
+ }
+
+ @Override
+ public void onNotification(NotificationSpec notificationSpec) {
+ String body = notificationSpec.body;
+ String title = notificationSpec.title;
+ if (title.isEmpty()) {
+ title = "Notification";
+ }
+ if (body.isEmpty()) {
+ body = title;
+ }
+ int titleLength = title.length();
+ int bodyLength = body.length();
+ if (titleLength > 32) {
+ titleLength = 32;
+ }
+ if (bodyLength > 223) {
+ bodyLength = 223;
+ } //Length is uint8 and its initial value is 32 (see below) so the body cannot exceed 255-32=223 bytes
+ byte[] args = new byte[33 + bodyLength]; //1 byte for len + 32 bytes are *always* allocated for the title, even if it's shorter than that
+
+ Arrays.fill(args, (byte) 0x00);
+ args[0] = (byte) (32 + bodyLength);
+ System.arraycopy(title.getBytes(), 0, args, 1, titleLength);
+ System.arraycopy(body.getBytes(), 0, args, 33, bodyLength);
+
+ sendCmd(OVTouch26Constants.CMD_NOTIFICATION, args, "onNotification");
+ }
+
+ @Override
+ public void onHeartRateTest() {
+ //NOTE: The proprietary app for this watch does not support on-demand heart rate reporting, meaning it will
+ //simply display the value of the last heart rate sample taken by the watch either through periodic tests
+ //or manually by pressing the associated icon on the watch's UI. As such, there seems to be no command for
+ //an on-demand heart rate test, and because the watch clears that value from memory once it's sent, there
+ //is a chance for this function to not return any value if, for example, it's used twice in a row
+ CurrentSample = createActivitySample();
+ sendCmd(OVTouch26Constants.CMD_GET_HEART_RATE, OVTouch26Constants.ARGS_NONE, "getHeartRate");
+ }
+
+ @Override
+ public boolean getImplicitCallbackModify() {
+ return true;
+ }
+
+ @Override
+ public boolean getSendWriteRequestResponse() {
+ return false;
+ }
+
+ public void onSendConfiguration(String config) {
+ switch (config) {
+ case DeviceSettingsPreferenceConst.PREF_BATTERY_POLLING_ENABLE:
+ if (!GBApplication.getDevicePrefs(gbDevice).getBatteryPollingEnabled()) {
+ stopBatteryRunnerDelayed();
+ break;
+ }
+ // Fall through if enabled
+ case DeviceSettingsPreferenceConst.PREF_BATTERY_POLLING_INTERVAL:
+ if (!startBatteryRunnerDelayed()) {
+ GB.toast(getContext(), R.string.battery_polling_failed_start, Toast.LENGTH_SHORT, GB.ERROR);
+ LOG.error("Failed to start the battery polling");
+ }
+ break;
+ }
+ }
+
+ @Override
+ public void dispose() {
+ stopBatteryRunnerDelayed();
+ super.dispose();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/CheckSums.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/CheckSums.java
index db2fe27ccc..373137a944 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/CheckSums.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/CheckSums.java
@@ -1,5 +1,5 @@
-/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Damien
- Gaignon, Daniele Gobbetti, José Rebelo, Petr Vaněk
+/* Copyright (C) 2015-2025 Andreas Shimokawa, Carsten Pfeiffer, Damien
+ Gaignon, Daniele Gobbetti, José Rebelo, Petr Vaněk, vappster
This file is part of Gadgetbridge.
@@ -73,7 +73,10 @@ public class CheckSums {
}
public static int getCRC16ansi(byte[] seq) {
- int crc = 0xffff;
+ return getCRC16ansi(seq, 0xffff);
+ }
+
+ public static int getCRC16ansi(byte[] seq, int crc) {
int polynomial = 0xA001;
for (int i = 0; i < seq.length; i++) {
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8584d59b94..45b8fe4a4d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -2025,6 +2025,7 @@
Mijia Temperature and Humidity Sensor 2 (E-ink)
Mijia MHO-C303
Makibes HR3
+ Overmax Touch 2.6
Bangle.js
TLW64
PineTime (JF Firmware)
diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinatorTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinatorTest.java
index fd0ba01b0a..e85d0d3cd9 100644
--- a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinatorTest.java
+++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinatorTest.java
@@ -159,6 +159,7 @@ public class AbstractDeviceCoordinatorTest extends TestBase {
put("M4-4711", DeviceType.FITPRO);
put("C20", DeviceType.C20); // #4070
put("C 20", DeviceType.C20); // #5495
+ put("OV-Touch2.6_LE", DeviceType.OVTOUCH26);
}};
for (Map.Entry e : bluetoothNameToExpectedType.entrySet()) {