OVERMAX Touch 2.6: Initial support

This commit is contained in:
vappster
2025-12-21 19:05:02 +01:00
committed by José Rebelo
parent f1c551b308
commit e21db811e3
9 changed files with 814 additions and 3 deletions
@@ -75,6 +75,7 @@ public class GBDaoGenerator {
Entity userDefinedActivityOverlay = addActivityDescription(schema, tag, user); Entity userDefinedActivityOverlay = addActivityDescription(schema, tag, user);
addMakibesHR3ActivitySample(schema, user, device); addMakibesHR3ActivitySample(schema, user, device);
addOVTouch26ActivitySample(schema, user, device);
addMiBandActivitySample(schema, user, device); addMiBandActivitySample(schema, user, device);
addHuamiExtendedActivitySample(schema, user, device); addHuamiExtendedActivitySample(schema, user, device);
addHuamiStressSample(schema, user, device); addHuamiStressSample(schema, user, device);
@@ -340,6 +341,19 @@ public class GBDaoGenerator {
return activitySample; 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) { private static Entity addMiBandActivitySample(Schema schema, Entity user, Entity device) {
Entity activitySample = addEntity(schema, "MiBandActivitySample"); Entity activitySample = addEntity(schema, "MiBandActivitySample");
activitySample.implementsSerializable(); activitySample.implementsSerializable();
@@ -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 <https://www.gnu.org/licenses/>. */
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. */
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<AbstractDao<?, ?>, Property> getAllDeviceDao(@NonNull final DaoSession session) {
Map<AbstractDao<?, ?>, Property> map = new HashMap<>(1);
map.put(session.getOVTouch26ActivitySampleDao(), OVTouch26ActivitySampleDao.Properties.DeviceId);
return map;
}
}
@@ -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 <https://www.gnu.org/licenses/>. */
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<OVTouch26ActivitySample> {
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<OVTouch26ActivitySample, ?> 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<OVTouch26ActivitySample> 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<OVTouch26ActivitySample> 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;
}
}
@@ -334,6 +334,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.onemoresonoflow.OneMoreSonoF
import nodomain.freeyourgadget.gadgetbridge.devices.oppo.OppoEncoAir2Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.oppo.OppoEncoAir2Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.oppo.OppoEncoAirCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.oppo.OppoEncoAirCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.oppo.OppoEncoBuds2Coordinator; 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.pebble.PebbleCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.pinetime.PineTimeJFCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.pinetime.PineTimeJFCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.pixel.PixelBudsACoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.pixel.PixelBudsACoordinator;
@@ -569,6 +570,7 @@ public enum DeviceType {
MICOMPOSITIONSCALE(MiCompositionScaleCoordinator.class), MICOMPOSITIONSCALE(MiCompositionScaleCoordinator.class),
BFH16(BFH16DeviceCoordinator.class), BFH16(BFH16DeviceCoordinator.class),
MAKIBESHR3(MakibesHR3Coordinator.class), MAKIBESHR3(MakibesHR3Coordinator.class),
OVTOUCH26(OVTouch26Coordinator.class),
BANGLEJS(BangleJSCoordinator.class), BANGLEJS(BangleJSCoordinator.class),
FOSSILQHYBRID(QHybridCoordinator.class), FOSSILQHYBRID(QHybridCoordinator.class),
TLW64(TLW64Coordinator.class), TLW64(TLW64Coordinator.class),
@@ -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 <https://www.gnu.org/licenses/>. */
//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<OVTouch26DeviceSupport> 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();
}
}
@@ -1,5 +1,5 @@
/* Copyright (C) 2015-2024 Andreas Shimokawa, Carsten Pfeiffer, Damien /* Copyright (C) 2015-2025 Andreas Shimokawa, Carsten Pfeiffer, Damien
Gaignon, Daniele Gobbetti, José Rebelo, Petr Vaněk Gaignon, Daniele Gobbetti, José Rebelo, Petr Vaněk, vappster
This file is part of Gadgetbridge. This file is part of Gadgetbridge.
@@ -73,7 +73,10 @@ public class CheckSums {
} }
public static int getCRC16ansi(byte[] seq) { public static int getCRC16ansi(byte[] seq) {
int crc = 0xffff; return getCRC16ansi(seq, 0xffff);
}
public static int getCRC16ansi(byte[] seq, int crc) {
int polynomial = 0xA001; int polynomial = 0xA001;
for (int i = 0; i < seq.length; i++) { for (int i = 0; i < seq.length; i++) {
+1
View File
@@ -2025,6 +2025,7 @@
<string name="devicetype_mijia_xmwsdj04" translatable="false">Mijia Temperature and Humidity Sensor 2 (E-ink)</string> <string name="devicetype_mijia_xmwsdj04" translatable="false">Mijia Temperature and Humidity Sensor 2 (E-ink)</string>
<string name="devicetype_mijia_mho_c303" translatable="false">Mijia MHO-C303</string> <string name="devicetype_mijia_mho_c303" translatable="false">Mijia MHO-C303</string>
<string name="devicetype_makibes_hr3" translatable="false">Makibes HR3</string> <string name="devicetype_makibes_hr3" translatable="false">Makibes HR3</string>
<string name="devicetype_ovtouch_26" translatable="false">Overmax Touch 2.6</string>
<string name="devicetype_banglejs" translatable="false">Bangle.js</string> <string name="devicetype_banglejs" translatable="false">Bangle.js</string>
<string name="devicetype_tlw64" translatable="false">TLW64</string> <string name="devicetype_tlw64" translatable="false">TLW64</string>
<string name="devicetype_pinetime_jf" translatable="false">PineTime (JF Firmware)</string> <string name="devicetype_pinetime_jf" translatable="false">PineTime (JF Firmware)</string>
@@ -159,6 +159,7 @@ public class AbstractDeviceCoordinatorTest extends TestBase {
put("M4-4711", DeviceType.FITPRO); put("M4-4711", DeviceType.FITPRO);
put("C20", DeviceType.C20); // #4070 put("C20", DeviceType.C20); // #4070
put("C 20", DeviceType.C20); // #5495 put("C 20", DeviceType.C20); // #5495
put("OV-Touch2.6_LE", DeviceType.OVTOUCH26);
}}; }};
for (Map.Entry<String, DeviceType> e : bluetoothNameToExpectedType.entrySet()) { for (Map.Entry<String, DeviceType> e : bluetoothNameToExpectedType.entrySet()) {