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 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 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()) {