mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
Casio GBD200 device support (#6070)
In this pull request I separated the implementation of the GBD200 from the common implementation of GBX100 since during protocol reverse engineering (done here: github.com/dakk/casio-gshock-bt-proto/) I've found some discrepancies in the BLE protocol, and in my opinion it is better to separate since they are too different in many ways. The flow works from initial pairing, connection, workout / step sync, configuration sync. The following feature has been implemented and tested: - [x] Watch connection and handshake - [x] Config sync - [x] Goal settings - [x] Phone finder - [x] Sport activity start and stop events - [x] Sport activity sync with segment data - [x] Step data sync - [x] Notifications - [x] Sport activity and OpenTracks - [x] Alarm setting Non Casio related edit: - OpenTracksController.saveToGpx now handles empty activity (since gbd200 has no gps, and cannot handle gps data, running GBLocationService would be useless) It should also address some of the following issues: - https://codeberg.org/Freeyourgadget/Gadgetbridge/issues/5529 - https://codeberg.org/Freeyourgadget/Gadgetbridge/issues/2634 - https://codeberg.org/Freeyourgadget/Gadgetbridge/issues/2597 - https://codeberg.org/Freeyourgadget/Gadgetbridge/issues/5096 - https://codeberg.org/Freeyourgadget/Gadgetbridge/issues/3395 - https://codeberg.org/Freeyourgadget/Gadgetbridge/issues/2602 - https://codeberg.org/Freeyourgadget/Gadgetbridge/issues/4095 - https://codeberg.org/Freeyourgadget/Gadgetbridge/issues/4092 - (test all the GBD200 related issues https://codeberg.org/Freeyourgadget/Gadgetbridge/issues?state=open&type=all&labels=&milestone=0&project=0&assignee=0&poster=0&sort=relevance&q=GBD-200) Todo: - [x] Test for some day on my wrist     Reviewed-on: https://codeberg.org/Freeyourgadget/Gadgetbridge/pulls/6070
This commit is contained in:
committed by
José Rebelo
parent
22859347f8
commit
491f214611
+90
@@ -0,0 +1,90 @@
|
||||
/* Copyright (C) 2026 Davide Gessa
|
||||
|
||||
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.casio.gbd200;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
|
||||
import nodomain.freeyourgadget.gadgetbridge.R;
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.casio.gbx100.CasioGBX100DeviceCoordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
|
||||
import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.DeviceType;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gbd200.CasioGBD200DeviceSupport;
|
||||
|
||||
public class CasioGBD200DeviceCoordinator extends CasioGBX100DeviceCoordinator {
|
||||
|
||||
@Override
|
||||
public GBDevice createDevice(final GBDeviceCandidate candidate, final DeviceType deviceType) {
|
||||
final GBDevice device = super.createDevice(candidate, deviceType);
|
||||
GBApplication.getDevicePrefs(device).getPreferences().edit()
|
||||
.putString(DeviceSettingsPreferenceConst.PREFS_DEVICE_CHARTS_TABS,
|
||||
"activity,activitylist,stepsweek")
|
||||
.apply();
|
||||
return device;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Pattern getSupportedDeviceName() {
|
||||
return Pattern.compile("^CASIO GBD-200.*");
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Class<? extends DeviceSupport> getDeviceSupportClass(final GBDevice device) {
|
||||
return CasioGBD200DeviceSupport.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getDeviceNameResource() {
|
||||
return R.string.devicetype_casiogbd200;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsRecordedActivities(@NonNull final GBDevice device) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ActivitySummaryParser getActivitySummaryParser(final GBDevice device, final Context context) {
|
||||
return (summary, forDetails) -> summary;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
|
||||
return new int[]{
|
||||
R.xml.devicesettings_find_phone,
|
||||
R.xml.devicesettings_wearlocation,
|
||||
R.xml.devicesettings_timeformat,
|
||||
R.xml.devicesettings_autolight,
|
||||
R.xml.devicesettings_key_vibration,
|
||||
R.xml.devicesettings_operating_sounds,
|
||||
R.xml.devicesettings_fake_ring_duration,
|
||||
R.xml.devicesettings_autoremove_message,
|
||||
R.xml.devicesettings_transliteration,
|
||||
R.xml.devicesettings_preview_message_in_title,
|
||||
R.xml.devicesettings_casio_alert
|
||||
};
|
||||
}
|
||||
}
|
||||
-1
@@ -56,7 +56,6 @@ public class CasioGBX100DeviceCoordinator extends Casio2C2DDeviceCoordinator {
|
||||
|
||||
public static final String[] VARIANTS = {
|
||||
GBX_100_SUB_MODEL,
|
||||
GBD_200_SUB_MODEL,
|
||||
GBD_100_SUB_MODEL,
|
||||
GBD_H1000_SUB_MODEL};
|
||||
|
||||
|
||||
+5
@@ -154,6 +154,11 @@ public class OpenTracksController extends Activity {
|
||||
}
|
||||
|
||||
private static void saveToGpx(ActivityTrack activityTrack) {
|
||||
if (activityTrack == null || activityTrack.getSegments().isEmpty()) {
|
||||
LOG.debug("No GPS track points to save — skipping GPX export");
|
||||
return;
|
||||
}
|
||||
|
||||
final SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault());
|
||||
final String gpxName = sdf.format(new Date());
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.braun.BraunBPW4500DeviceCoor
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.casio.ecbs100.CasioECBS100DeviceCoordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.casio.gb6900.CasioGB6900DeviceCoordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.casio.gbx100.CasioGBX100DeviceCoordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.casio.gbd200.CasioGBD200DeviceCoordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.casio.gwb5600.CasioGMWB5000DeviceCoordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.casio.gwb5600.CasioGWB5600DeviceCoordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.CmfWatchPro2Coordinator;
|
||||
@@ -625,6 +626,7 @@ public enum DeviceType {
|
||||
|
||||
CASIOGB6900(CasioGB6900DeviceCoordinator.class),
|
||||
CASIOGBX100(CasioGBX100DeviceCoordinator.class),
|
||||
CASIOGBD200(CasioGBD200DeviceCoordinator.class),
|
||||
CASIOGWB5600(CasioGWB5600DeviceCoordinator.class),
|
||||
CASIOGMWB5000(CasioGMWB5000DeviceCoordinator.class),
|
||||
MISMARTSCALE(MiSmartScaleCoordinator.class),
|
||||
|
||||
+12
@@ -104,10 +104,22 @@ public abstract class Casio2C2DSupport extends CasioSupport {
|
||||
public static final byte FEATURE_SETTING_FOR_TARGET_VALUE = 0x43;
|
||||
public static final byte FEATURE_SETTING_FOR_USER_PROFILE = 0x45;
|
||||
public static final byte FEATURE_SERVICE_DISCOVERY_MANAGER = 0x47;
|
||||
public static final byte FEATURE_SESSION_EVENT = 0x48;
|
||||
// GBD-200 specific features
|
||||
public static final byte FEATURE_GPS = 0x24;
|
||||
public static final byte FEATURE_FEAT_2F = 0x2f;
|
||||
public static final byte FEATURE_CONVOY_INIT = 0x1c;
|
||||
public static final byte FEATURE_BLE_PARAM = 0x3d;
|
||||
|
||||
protected static Logger LOG;
|
||||
LinkedList<RequestWithHandler> requests = new LinkedList<>();
|
||||
|
||||
/** Called when a GetConfigurationOperation finishes. Override in device support subclasses. */
|
||||
public void onGetConfigurationFinished() {}
|
||||
|
||||
/** Sync user profile / settings to the watch. Override in device support subclasses. */
|
||||
public void syncProfile() {}
|
||||
|
||||
public Casio2C2DSupport(Logger logger) {
|
||||
super(logger);
|
||||
LOG = logger;
|
||||
|
||||
+744
@@ -0,0 +1,744 @@
|
||||
/* Copyright (C) 2026 Davide Gessa
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gbd200;
|
||||
|
||||
import android.Manifest;
|
||||
import android.bluetooth.BluetoothGatt;
|
||||
import android.bluetooth.BluetoothGattCharacteristic;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.location.Location;
|
||||
import android.location.LocationManager;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.core.app.ActivityCompat;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
|
||||
import nodomain.freeyourgadget.gadgetbridge.R;
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
|
||||
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
|
||||
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
|
||||
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventFindPhone;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.casio.CasioConstants;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.casio.gbx100.CasioGBX100SampleProvider;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.CasioGBX100ActivitySample;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.Device;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.User;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.Alarm;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.CallSpec;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.casio.Casio2C2DSupport;
|
||||
import nodomain.freeyourgadget.gadgetbridge.externalevents.opentracks.OpenTracksController;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.GB;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
|
||||
|
||||
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_AUTOLIGHT;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_AUTOREMOVE_MESSAGE;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_CASIO_ALERT_CALENDAR;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_CASIO_ALERT_CALL;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_CASIO_ALERT_EMAIL;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_CASIO_ALERT_OTHER;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_CASIO_ALERT_SMS;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_FAKE_RING_DURATION;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_FIND_PHONE;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_FIND_PHONE_DURATION;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_KEY_VIBRATION;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_OPERATING_SOUNDS;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_PREVIEW_MESSAGE_IN_TITLE;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_TIMEFORMAT;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_ACTIVETIME_MINUTES;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_DATE_OF_BIRTH;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_DISTANCE_METERS;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_GENDER;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_HEIGHT_CM;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_STEPS_GOAL;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_WEIGHT_KG;
|
||||
|
||||
public class CasioGBD200DeviceSupport extends Casio2C2DSupport
|
||||
implements SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(CasioGBD200DeviceSupport.class);
|
||||
|
||||
private boolean mGetConfigurationPending = false;
|
||||
private boolean mRingNotificationPending = false;
|
||||
private boolean mNeedsGetConfiguration = false;
|
||||
private boolean mResyncPending = false;
|
||||
private final ArrayList<Integer> mSyncedNotificationIDs = new ArrayList<>();
|
||||
private int mLastCallId = (int) (System.currentTimeMillis() / 1000) + 1;
|
||||
private int mFakeRingDurationCounter = 0;
|
||||
private final Handler mFindPhoneHandler = new Handler(Looper.getMainLooper());
|
||||
private final Handler mFakeRingDurationHandler = new Handler(Looper.getMainLooper());
|
||||
private final Handler mAutoRemoveMessageHandler = new Handler(Looper.getMainLooper());
|
||||
private final Handler mReconnectHandler = new Handler(Looper.getMainLooper());
|
||||
|
||||
public CasioGBD200DeviceSupport() {
|
||||
super(LOG);
|
||||
}
|
||||
|
||||
// ── GPS ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Returns [lat, lon, alt] from the last known location, or [0, 0, 0]. */
|
||||
public double[] getLastKnownLocation() {
|
||||
try {
|
||||
LocationManager lm = (LocationManager)
|
||||
getContext().getSystemService(android.content.Context.LOCATION_SERVICE);
|
||||
if (lm == null) return new double[]{0, 0, 0};
|
||||
if (ActivityCompat.checkSelfPermission(getContext(),
|
||||
Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
|
||||
LOG.warn("No location permission; using 0,0,0 for GPS");
|
||||
return new double[]{0, 0, 0};
|
||||
}
|
||||
Location loc = lm.getLastKnownLocation(LocationManager.NETWORK_PROVIDER);
|
||||
if (loc == null) loc = lm.getLastKnownLocation(LocationManager.GPS_PROVIDER);
|
||||
if (loc == null) return new double[]{0, 0, 0};
|
||||
return new double[]{loc.getLatitude(), loc.getLongitude(), loc.getAltitude()};
|
||||
} catch (Exception e) {
|
||||
LOG.warn("getLastKnownLocation failed: {}", e.getMessage());
|
||||
return new double[]{0, 0, 0};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Initialization ────────────────────────────────────────────────────────
|
||||
|
||||
private void resetState() {
|
||||
mFindPhoneHandler.removeCallbacksAndMessages(null);
|
||||
mFakeRingDurationHandler.removeCallbacksAndMessages(null);
|
||||
mAutoRemoveMessageHandler.removeCallbacksAndMessages(null);
|
||||
mReconnectHandler.removeCallbacksAndMessages(null);
|
||||
|
||||
mGetConfigurationPending = false;
|
||||
mRingNotificationPending = false;
|
||||
mResyncPending = false;
|
||||
mFakeRingDurationCounter = 0;
|
||||
mSyncedNotificationIDs.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
resetState();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected TransactionBuilder initializeDevice(TransactionBuilder builder) {
|
||||
resetState();
|
||||
|
||||
try {
|
||||
new InitOperation(this, builder, mFirstConnect, mNeedsGetConfiguration).perform();
|
||||
} catch (IOException e) {
|
||||
GB.toast(getContext(), "Initializing Casio GBD-200 failed",
|
||||
Toast.LENGTH_SHORT, GB.ERROR, e);
|
||||
}
|
||||
|
||||
getDevice().setFirmwareVersion("N/A");
|
||||
getDevice().setFirmwareVersion2("N/A");
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
// ── Time sync ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Override to use the correct DOW encoding: Sunday=0, Monday=1 … Saturday=6.
|
||||
* The standard BLETypeConversions uses DayOfWeek.getValue() (Mon=1…Sun=7) which
|
||||
* is wrong for this protocol.
|
||||
*/
|
||||
@Override
|
||||
public void writeCurrentTime(TransactionBuilder builder, ZonedDateTime time) {
|
||||
int dow = time.getDayOfWeek() == DayOfWeek.SUNDAY ? 0 : time.getDayOfWeek().getValue();
|
||||
byte[] arr = new byte[11];
|
||||
arr[0] = FEATURE_CURRENT_TIME;
|
||||
arr[1] = (byte) (time.getYear() & 0xff);
|
||||
arr[2] = (byte) ((time.getYear() >> 8) & 0xff);
|
||||
arr[3] = (byte) time.getMonthValue();
|
||||
arr[4] = (byte) time.getDayOfMonth();
|
||||
arr[5] = (byte) time.getHour();
|
||||
arr[6] = (byte) time.getMinute();
|
||||
arr[7] = (byte) time.getSecond();
|
||||
arr[8] = (byte) dow;
|
||||
arr[9] = 0x00; // fractions256
|
||||
arr[10] = 0x01; // reason = manual sync
|
||||
writeAllFeatures(builder, arr);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSetTime() {
|
||||
try {
|
||||
TransactionBuilder builder = performInitialized("onSetTime");
|
||||
writeCurrentTime(builder, ZonedDateTime.now());
|
||||
builder.queue();
|
||||
} catch (IOException e) {
|
||||
LOG.warn("onSetTime failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Config sync ───────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public void syncProfile() {
|
||||
try {
|
||||
new nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gbx100
|
||||
.SetConfigurationOperation(this,
|
||||
CasioConstants.ConfigurationOption.OPTION_ALL).perform();
|
||||
} catch (IOException e) {
|
||||
GB.toast(getContext(), "Sending Casio configuration failed",
|
||||
Toast.LENGTH_SHORT, GB.ERROR, e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main-menu resync (3d 01) ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* When the user navigates to the watch main menu, it sends BLE_PARAM (3d 01) and resets
|
||||
* its CONVOY state. We must toggle CCCDs and run a dummy steps fetch to warm the state
|
||||
* machine before the next sport or steps command.
|
||||
*/
|
||||
private void handleMainMenuResync() {
|
||||
LOG.info("Main-menu resync triggered (3d 01)");
|
||||
mResyncPending = true;
|
||||
try {
|
||||
TransactionBuilder b1 = performInitialized("resync_cccd_off");
|
||||
b1.notify(CasioConstants.CASIO_DATA_REQUEST_SP_CHARACTERISTIC_UUID, false);
|
||||
b1.notify(CasioConstants.CASIO_CONVOY_CHARACTERISTIC_UUID, false);
|
||||
b1.queue();
|
||||
|
||||
TransactionBuilder b2 = performInitialized("resync_cccd_on");
|
||||
b2.notify(CasioConstants.CASIO_DATA_REQUEST_SP_CHARACTERISTIC_UUID, true);
|
||||
b2.notify(CasioConstants.CASIO_CONVOY_CHARACTERISTIC_UUID, true);
|
||||
b2.writeLegacy(
|
||||
getCharacteristic(CasioConstants.CASIO_DATA_REQUEST_SP_CHARACTERISTIC_UUID),
|
||||
new byte[]{0x00, FEATURE_SETTING_FOR_BLE, 0x00, 0x00, 0x00});
|
||||
b2.queue();
|
||||
} catch (IOException e) {
|
||||
mResyncPending = false;
|
||||
LOG.error("handleMainMenuResync failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Characteristic change handler ─────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public boolean onCharacteristicChanged(BluetoothGatt gatt,
|
||||
BluetoothGattCharacteristic characteristic,
|
||||
byte[] data) {
|
||||
UUID uuid = characteristic.getUuid();
|
||||
if (data == null || data.length == 0) return true;
|
||||
|
||||
if (uuid.equals(CasioConstants.CASIO_ALL_FEATURES_CHARACTERISTIC_UUID)) {
|
||||
byte feat = data[0];
|
||||
|
||||
if (feat == FEATURE_ALERT_LEVEL) {
|
||||
// Phone finder: 0x02 = start, other = stop
|
||||
onReverseFindDevice(data.length > 1 && data[1] == 0x02);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (feat == FEATURE_CURRENT_TIME_MANAGER && data.length > 1 && data[1] == 0x00) {
|
||||
// Watch requests time resync
|
||||
try {
|
||||
TransactionBuilder b = performInitialized("writeCurrentTime");
|
||||
writeCurrentTime(b, ZonedDateTime.now());
|
||||
b.queue();
|
||||
} catch (IOException e) {
|
||||
LOG.warn("Time resync failed: {}", e.getMessage());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (feat == FEATURE_BLE_PARAM && data.length > 1 && data[1] == 0x01) {
|
||||
// Watch navigated to main menu → must resync CONVOY state
|
||||
handleMainMenuResync();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (feat == FEATURE_WATCH_CONDITION) {
|
||||
if (data.length >= 9) {
|
||||
if (data[7] == 0x01) {
|
||||
LOG.info("Session saved on watch");
|
||||
} else {
|
||||
LOG.info("Session discarded on watch — OpenTracks recording already stopped");
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (feat == FEATURE_SESSION_EVENT && data.length > 1) {
|
||||
if (data[1] == 0x00) {
|
||||
// Running session started on watch
|
||||
LOG.info("Running session started on watch (0x48 0x00)");
|
||||
try {
|
||||
TransactionBuilder b = performInitialized("session_event_ack");
|
||||
writeAllFeatures(b, new byte[]{
|
||||
FEATURE_SESSION_EVENT, 0x03, 0x00,
|
||||
(byte) 0xc8, 0x00, // GPS rate 200
|
||||
0x14, 0x0a, // thresholds
|
||||
0x00, 0x00, // elapsed_s (0 at start)
|
||||
0x34, 0x01, // target pace (308 s/km)
|
||||
0x40, 0x01, // target step count (320)
|
||||
0x01, 0x00,
|
||||
(byte) 0xdc, 0x05 // target distance (1500 m)
|
||||
});
|
||||
b.queue();
|
||||
} catch (IOException e) {
|
||||
LOG.warn("session_event_ack failed: {}", e.getMessage());
|
||||
}
|
||||
OpenTracksController.startRecording(getContext(), ActivityKind.RUNNING);
|
||||
} else if (data[1] == 0x01) {
|
||||
LOG.info("Running session ended on watch (0x48 0x01)");
|
||||
OpenTracksController.stopRecording(getContext());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Warm-up steps ACK arrives on DATA_REQ_SP (h0011), not ALL_FEATURES.
|
||||
// Only handle when a resync was explicitly triggered — not during normal step fetches.
|
||||
if (mResyncPending
|
||||
&& uuid.equals(CasioConstants.CASIO_DATA_REQUEST_SP_CHARACTERISTIC_UUID)
|
||||
&& data.length >= 2 && data[0] == 0x00 && data[1] == FEATURE_SETTING_FOR_BLE) {
|
||||
mResyncPending = false;
|
||||
try {
|
||||
TransactionBuilder b = performInitialized("resync_steps_ack");
|
||||
b.writeLegacy(
|
||||
getCharacteristic(CasioConstants.CASIO_DATA_REQUEST_SP_CHARACTERISTIC_UUID),
|
||||
new byte[]{0x04, FEATURE_SETTING_FOR_BLE, 0x00, 0x00, 0x00});
|
||||
b.notify(CasioConstants.CASIO_DATA_REQUEST_SP_CHARACTERISTIC_UUID, false);
|
||||
b.notify(CasioConstants.CASIO_CONVOY_CHARACTERISTIC_UUID, false);
|
||||
b.notify(CasioConstants.CASIO_DATA_REQUEST_SP_CHARACTERISTIC_UUID, true);
|
||||
b.notify(CasioConstants.CASIO_CONVOY_CHARACTERISTIC_UUID, true);
|
||||
b.queue();
|
||||
} catch (IOException e) {
|
||||
LOG.warn("resync_steps_ack failed: {}", e.getMessage());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
LOG.info("Unhandled characteristic change: {} feat=0x{}", uuid,
|
||||
String.format("%02x", data[0]));
|
||||
return super.onCharacteristicChanged(gatt, characteristic, data);
|
||||
}
|
||||
|
||||
// ── Data fetch ────────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public void onFetchRecordedData(int dataTypes) {
|
||||
try {
|
||||
new FetchStepCountDataOperation(this).perform();
|
||||
} catch (IOException e) {
|
||||
GB.toast(getContext(), "Error fetching step data",
|
||||
Toast.LENGTH_SHORT, GB.ERROR, e);
|
||||
// Still attempt sport fetch even if step count fails
|
||||
startFetchSportData();
|
||||
}
|
||||
}
|
||||
|
||||
/** Called by FetchStepCountDataOperation when it finishes, to chain the sport fetch. */
|
||||
public void onStepCountFetchFinished() {
|
||||
startFetchSportData();
|
||||
}
|
||||
|
||||
private void startFetchSportData() {
|
||||
try {
|
||||
new FetchSportDataOperation(this).perform();
|
||||
} catch (IOException e) {
|
||||
GB.toast(getContext(), "Error fetching sport data",
|
||||
Toast.LENGTH_SHORT, GB.ERROR, e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Activity DB helpers (used by FetchStepCountDataOperation) ─────────────
|
||||
|
||||
public CasioGBX100ActivitySample getSumWithinRange(int ts_from, int ts_to) {
|
||||
int steps = 0, calories = 0;
|
||||
try (DBHandler db = GBApplication.acquireDB()) {
|
||||
User user = DBHelper.getUser(db.getDaoSession());
|
||||
Device device = DBHelper.getDevice(this.getDevice(), db.getDaoSession());
|
||||
CasioGBX100SampleProvider provider =
|
||||
new CasioGBX100SampleProvider(this.getDevice(), db.getDaoSession());
|
||||
List<CasioGBX100ActivitySample> samples =
|
||||
provider.getActivitySamples(ts_from, ts_to);
|
||||
for (CasioGBX100ActivitySample s : samples) {
|
||||
if (s.getDevice().equals(device) && s.getUser().equals(user)) {
|
||||
steps += s.getSteps();
|
||||
calories += s.getCalories();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.error("Error fetching step sum: {}", e.getMessage());
|
||||
}
|
||||
CasioGBX100ActivitySample ret = new CasioGBX100ActivitySample();
|
||||
ret.setSteps(steps);
|
||||
ret.setCalories(calories);
|
||||
return ret;
|
||||
}
|
||||
|
||||
public void stepCountDataFetched(int totalSteps, int totalCalories,
|
||||
ArrayList<CasioGBX100ActivitySample> data) {
|
||||
LOG.info("Steps fetched: total={} kcal={}", totalSteps, totalCalories);
|
||||
try (DBHandler db = GBApplication.acquireDB()) {
|
||||
User user = DBHelper.getUser(db.getDaoSession());
|
||||
Device device = DBHelper.getDevice(this.getDevice(), db.getDaoSession());
|
||||
CasioGBX100SampleProvider provider =
|
||||
new CasioGBX100SampleProvider(this.getDevice(), db.getDaoSession());
|
||||
for (CasioGBX100ActivitySample s : data) {
|
||||
s.setDevice(device);
|
||||
s.setUser(user);
|
||||
s.setProvider(provider);
|
||||
provider.addGBActivitySample(s);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
GB.toast(getContext(), "Error saving steps: " + e.getLocalizedMessage(),
|
||||
Toast.LENGTH_LONG, GB.ERROR, e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notifications ─────────────────────────────────────────────────────────
|
||||
|
||||
private void showNotification(byte icon, String sender, String title,
|
||||
String subtitle, String message, int id, boolean delete) {
|
||||
SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(
|
||||
getDevice().getAddress());
|
||||
boolean showPreview = prefs.getBoolean(PREF_PREVIEW_MESSAGE_IN_TITLE, true);
|
||||
|
||||
boolean shouldAlert = true;
|
||||
switch (icon) {
|
||||
case CasioConstants.CATEGORY_EMAIL:
|
||||
shouldAlert = prefs.getBoolean(PREF_CASIO_ALERT_EMAIL, true); break;
|
||||
case CasioConstants.CATEGORY_OTHER:
|
||||
shouldAlert = prefs.getBoolean(PREF_CASIO_ALERT_OTHER, true); break;
|
||||
case CasioConstants.CATEGORY_SNS:
|
||||
shouldAlert = prefs.getBoolean(PREF_CASIO_ALERT_SMS, true); break;
|
||||
case CasioConstants.CATEGORY_INCOMING_CALL:
|
||||
shouldAlert = prefs.getBoolean(PREF_CASIO_ALERT_CALL, true); break;
|
||||
case CasioConstants.CATEGORY_SCHEDULE_AND_ALARM:
|
||||
shouldAlert = prefs.getBoolean(PREF_CASIO_ALERT_CALENDAR, true); break;
|
||||
}
|
||||
|
||||
if (showPreview && icon != CasioConstants.CATEGORY_INCOMING_CALL
|
||||
&& icon != CasioConstants.CATEGORY_EMAIL) {
|
||||
if (StringUtils.isNullOrEmpty(sender)) sender = title;
|
||||
if (!StringUtils.isNullOrEmpty(message))
|
||||
title = message.substring(0, Math.min(message.length(), 18)) + "..";
|
||||
}
|
||||
|
||||
byte[] titleBytes = StringUtils.isNullOrEmpty(title) ? new byte[0]
|
||||
: truncate(title, 32).getBytes(StandardCharsets.UTF_8);
|
||||
byte[] senderBytes = StringUtils.isNullOrEmpty(sender) ? new byte[0]
|
||||
: truncate(sender, 32).getBytes(StandardCharsets.UTF_8);
|
||||
byte[] subtitleBytes = StringUtils.isNullOrEmpty(subtitle) ? new byte[0]
|
||||
: StringUtils.truncateToBytes(subtitle, 32);
|
||||
byte[] messageBytes = StringUtils.isNullOrEmpty(message) ? new byte[0]
|
||||
: StringUtils.truncateToBytes(message, 250);
|
||||
|
||||
byte[] hdr = new byte[22];
|
||||
hdr[0] = (byte) (id & 0xff);
|
||||
hdr[1] = (byte) ((id >> 8) & 0xff);
|
||||
hdr[2] = (byte) ((id >> 16) & 0xff);
|
||||
hdr[3] = (byte) ((id >> 24) & 0xff);
|
||||
hdr[4] = delete ? (byte) 0x02 : (byte) 0x00;
|
||||
hdr[5] = shouldAlert ? (byte) 0x01 : (byte) 0x00;
|
||||
hdr[6] = icon;
|
||||
String ts = new SimpleDateFormat("yyyyMMdd'T'HHmmss").format(new Date());
|
||||
System.arraycopy(ts.getBytes(), 0, hdr, 7, 15);
|
||||
|
||||
byte[] pkt = tlvConcat(hdr, senderBytes, titleBytes, subtitleBytes, messageBytes);
|
||||
// XOR all bytes with 0xFF
|
||||
for (int i = 0; i < pkt.length; i++) pkt[i] = (byte) (~pkt[i]);
|
||||
|
||||
try {
|
||||
TransactionBuilder b = performInitialized("showNotification");
|
||||
b.writeLegacy(getCharacteristic(CasioConstants.CASIO_NOTIFICATION_CHARACTERISTIC_UUID),
|
||||
pkt);
|
||||
b.queue();
|
||||
} catch (IOException e) {
|
||||
LOG.error("showNotification failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String truncate(String s, int maxLen) {
|
||||
return s.length() > maxLen ? s.substring(0, maxLen - 2) + ".." : s;
|
||||
}
|
||||
|
||||
/** Build header + TLV fields (LE16 length + bytes) for each string. */
|
||||
private byte[] tlvConcat(byte[] hdr, byte[]... fields) {
|
||||
int total = hdr.length;
|
||||
for (byte[] f : fields) total += 2 + f.length;
|
||||
byte[] out = Arrays.copyOf(hdr, total);
|
||||
int pos = hdr.length;
|
||||
for (byte[] f : fields) {
|
||||
out[pos] = (byte) (f.length & 0xff);
|
||||
out[pos + 1] = (byte) ((f.length >> 8) & 0xff);
|
||||
System.arraycopy(f, 0, out, pos + 2, f.length);
|
||||
pos += 2 + f.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private void showNotification(byte icon, String sender, String title, String message,
|
||||
int id, boolean delete) {
|
||||
showNotification(icon, sender, title, null, message, id, delete);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNotification(final NotificationSpec spec) {
|
||||
byte icon;
|
||||
boolean autoremove = false;
|
||||
switch (spec.type) {
|
||||
case GENERIC_CALENDAR:
|
||||
icon = CasioConstants.CATEGORY_SCHEDULE_AND_ALARM; break;
|
||||
case GENERIC_EMAIL:
|
||||
case GMAIL:
|
||||
case GOOGLE_INBOX:
|
||||
icon = CasioConstants.CATEGORY_EMAIL; break;
|
||||
case GENERIC_SMS:
|
||||
icon = CasioConstants.CATEGORY_SNS;
|
||||
autoremove = GBApplication.getDeviceSpecificSharedPrefs(getDevice().getAddress())
|
||||
.getBoolean(PREF_AUTOREMOVE_MESSAGE, false);
|
||||
break;
|
||||
case GENERIC_PHONE:
|
||||
icon = CasioConstants.CATEGORY_INCOMING_CALL; break;
|
||||
default:
|
||||
icon = CasioConstants.CATEGORY_OTHER; break;
|
||||
}
|
||||
showNotification(icon, spec.sender, spec.title, spec.body, spec.getId(), false);
|
||||
mSyncedNotificationIDs.add(spec.getId());
|
||||
if (autoremove) {
|
||||
mAutoRemoveMessageHandler.postDelayed(
|
||||
() -> onDeleteNotification(spec.getId()),
|
||||
CasioConstants.CASIO_AUTOREMOVE_MESSAGE_DELAY);
|
||||
}
|
||||
if (mSyncedNotificationIDs.size() > 100) mSyncedNotificationIDs.remove(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDeleteNotification(int id) {
|
||||
if (mSyncedNotificationIDs.contains(id)) {
|
||||
showNotification(CasioConstants.CATEGORY_OTHER, null, null, null, id, true);
|
||||
mSyncedNotificationIDs.remove(Integer.valueOf(id));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Calls ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public void onSetCallState(final CallSpec callSpec) {
|
||||
switch (callSpec.command) {
|
||||
case CallSpec.CALL_INCOMING:
|
||||
showNotification(CasioConstants.CATEGORY_INCOMING_CALL,
|
||||
callSpec.name, callSpec.number, "Phone Call", mLastCallId, false);
|
||||
SharedPreferences p = GBApplication.getDeviceSpecificSharedPrefs(
|
||||
getDevice().getAddress());
|
||||
if (p.getBoolean(PREF_FAKE_RING_DURATION, false)
|
||||
&& mFakeRingDurationCounter < CasioConstants.CASIO_FAKE_RING_RETRIES) {
|
||||
mFakeRingDurationCounter++;
|
||||
mFakeRingDurationHandler.postDelayed(() -> {
|
||||
showNotification(CasioConstants.CATEGORY_INCOMING_CALL,
|
||||
null, null, null, mLastCallId, true);
|
||||
onSetCallState(callSpec);
|
||||
}, CasioConstants.CASIO_FAKE_RING_SLEEP_DURATION);
|
||||
} else {
|
||||
mFakeRingDurationCounter = 0;
|
||||
}
|
||||
mRingNotificationPending = true;
|
||||
break;
|
||||
default:
|
||||
if (mRingNotificationPending) {
|
||||
mFakeRingDurationHandler.removeCallbacksAndMessages(null);
|
||||
mFakeRingDurationCounter = 0;
|
||||
showNotification(CasioConstants.CATEGORY_INCOMING_CALL,
|
||||
null, null, null, mLastCallId, true);
|
||||
mLastCallId = (int) (System.currentTimeMillis() / 1000) + 1;
|
||||
mRingNotificationPending = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phone finder ──────────────────────────────────────────────────────────
|
||||
|
||||
private void onReverseFindDevice(boolean start) {
|
||||
if (start) {
|
||||
String findPhone = getDevicePrefs().getString(PREF_FIND_PHONE,
|
||||
getContext().getString(R.string.p_off));
|
||||
if (findPhone.equals(getContext().getString(R.string.p_off))) return;
|
||||
|
||||
GBDeviceEventFindPhone ev = new GBDeviceEventFindPhone();
|
||||
ev.event = GBDeviceEventFindPhone.Event.START;
|
||||
evaluateGBDeviceEvent(ev);
|
||||
|
||||
if (findPhone.equals(getContext().getString(R.string.p_on))) {
|
||||
int duration = getDevicePrefs().getInt(PREF_FIND_PHONE_DURATION, 0);
|
||||
if (duration > 0) {
|
||||
mFindPhoneHandler.postDelayed(() -> {
|
||||
GBDeviceEventFindPhone stop = new GBDeviceEventFindPhone();
|
||||
stop.event = GBDeviceEventFindPhone.Event.STOP;
|
||||
evaluateGBDeviceEvent(stop);
|
||||
}, duration * 1000L);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
GBDeviceEventFindPhone ev = new GBDeviceEventFindPhone();
|
||||
ev.event = GBDeviceEventFindPhone.Event.STOP;
|
||||
evaluateGBDeviceEvent(ev);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Alarms ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public void onSetAlarms(ArrayList<? extends Alarm> alarms) {
|
||||
if (!isConnected()) return;
|
||||
|
||||
byte[] data1 = new byte[5];
|
||||
byte[] data2 = new byte[17];
|
||||
data1[0] = FEATURE_SETTING_FOR_ALM;
|
||||
data2[0] = FEATURE_SETTING_FOR_ALM2;
|
||||
|
||||
for (int i = 0; i < alarms.size(); i++) {
|
||||
Alarm a = alarms.get(i);
|
||||
byte[] s = new byte[4];
|
||||
s[0] = a.getEnabled() ? (a.getSnooze() ? (byte) 0x50 : (byte) 0x40) : 0x00;
|
||||
s[1] = 0x40;
|
||||
s[2] = (byte) a.getHour();
|
||||
s[3] = (byte) a.getMinute();
|
||||
if (i == 0) System.arraycopy(s, 0, data1, 1, 4);
|
||||
else System.arraycopy(s, 0, data2, 1 + (i - 1) * 4, 4);
|
||||
}
|
||||
try {
|
||||
TransactionBuilder b = performInitialized("setAlarm");
|
||||
writeAllFeatures(b, data1);
|
||||
writeAllFeatures(b, data2);
|
||||
b.queue();
|
||||
} catch (IOException e) {
|
||||
LOG.error("setAlarm failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public void onSendConfiguration(String config) {
|
||||
onSharedPreferenceChanged(null, config);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onGetConfigurationFinished() {
|
||||
mGetConfigurationPending = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReadConfiguration(String config) {
|
||||
if (config == null) {
|
||||
try {
|
||||
mGetConfigurationPending = true;
|
||||
mNeedsGetConfiguration = false;
|
||||
new nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gbx100
|
||||
.GetConfigurationOperation(this, true).perform();
|
||||
} catch (IOException e) {
|
||||
mGetConfigurationPending = false;
|
||||
GB.toast(getContext(), "Reading Casio config failed",
|
||||
Toast.LENGTH_SHORT, GB.ERROR, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSharedPreferenceChanged(SharedPreferences prefs, String key) {
|
||||
if (!isConnected() || mGetConfigurationPending) return;
|
||||
if (key == null) return;
|
||||
|
||||
try {
|
||||
switch (key) {
|
||||
case DeviceSettingsPreferenceConst.PREF_WEARLOCATION:
|
||||
new nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gbx100
|
||||
.SetConfigurationOperation(this, CasioConstants.ConfigurationOption.OPTION_WRIST).perform(); break;
|
||||
case PREF_USER_STEPS_GOAL:
|
||||
new nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gbx100
|
||||
.SetConfigurationOperation(this, CasioConstants.ConfigurationOption.OPTION_STEP_GOAL).perform(); break;
|
||||
case PREF_USER_ACTIVETIME_MINUTES:
|
||||
new nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gbx100
|
||||
.SetConfigurationOperation(this, CasioConstants.ConfigurationOption.OPTION_ACTIVITY_GOAL).perform(); break;
|
||||
case PREF_USER_DISTANCE_METERS:
|
||||
new nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gbx100
|
||||
.SetConfigurationOperation(this, CasioConstants.ConfigurationOption.OPTION_DISTANCE_GOAL).perform(); break;
|
||||
case PREF_USER_GENDER:
|
||||
new nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gbx100
|
||||
.SetConfigurationOperation(this, CasioConstants.ConfigurationOption.OPTION_GENDER).perform(); break;
|
||||
case PREF_USER_HEIGHT_CM:
|
||||
new nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gbx100
|
||||
.SetConfigurationOperation(this, CasioConstants.ConfigurationOption.OPTION_HEIGHT).perform(); break;
|
||||
case PREF_USER_WEIGHT_KG:
|
||||
new nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gbx100
|
||||
.SetConfigurationOperation(this, CasioConstants.ConfigurationOption.OPTION_WEIGHT).perform(); break;
|
||||
case PREF_USER_DATE_OF_BIRTH:
|
||||
new nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gbx100
|
||||
.SetConfigurationOperation(this, CasioConstants.ConfigurationOption.OPTION_BIRTHDAY).perform(); break;
|
||||
case PREF_TIMEFORMAT:
|
||||
new nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gbx100
|
||||
.SetConfigurationOperation(this, CasioConstants.ConfigurationOption.OPTION_TIMEFORMAT).perform(); break;
|
||||
case PREF_KEY_VIBRATION:
|
||||
new nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gbx100
|
||||
.SetConfigurationOperation(this, CasioConstants.ConfigurationOption.OPTION_KEY_VIBRATION).perform(); break;
|
||||
case PREF_AUTOLIGHT:
|
||||
new nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gbx100
|
||||
.SetConfigurationOperation(this, CasioConstants.ConfigurationOption.OPTION_AUTOLIGHT).perform(); break;
|
||||
case PREF_OPERATING_SOUNDS:
|
||||
new nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gbx100
|
||||
.SetConfigurationOperation(this, CasioConstants.ConfigurationOption.OPTION_OPERATING_SOUNDS).perform(); break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOG.info("onSharedPreferenceChanged: config send failed for {}", key);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reconnect helper ──────────────────────────────────────────────────────
|
||||
|
||||
public void reconnectDelayed() {
|
||||
setAutoReconnect(true);
|
||||
mNeedsGetConfiguration = true;
|
||||
mReconnectHandler.postDelayed(this::connect, CasioConstants.CASIO_FAKE_RING_SLEEP_DURATION);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean useAutoConnect() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DevicePreference[] supportedDevicePreferences() {
|
||||
return new DevicePreference[]{};
|
||||
}
|
||||
}
|
||||
+658
@@ -0,0 +1,658 @@
|
||||
/* Copyright (C) 2026 Davide Gessa
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gbd200;
|
||||
|
||||
import android.bluetooth.BluetoothGatt;
|
||||
import android.bluetooth.BluetoothGattCharacteristic;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.UUID;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryTableBuilder;
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.workouts.entries.ActivitySummaryValue;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
|
||||
import nodomain.freeyourgadget.gadgetbridge.R;
|
||||
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
|
||||
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.casio.CasioConstants;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.Device;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.User;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryData;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEOperation;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.miband.operations.OperationStatus;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.GB;
|
||||
|
||||
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.ACTIVE_SECONDS;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.CADENCE_AVG;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.CALORIES_BURNT;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.DISTANCE_METERS;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.PACE_AVG_SECONDS_KM;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_KCAL;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_METERS;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_SECONDS;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_SECONDS_PER_KM;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_SPM;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.GROUP_LAPS;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_NONE;
|
||||
|
||||
/**
|
||||
* Fetches all sport session summaries from a GBD-200 via the CONVOY handshake protocol.
|
||||
*
|
||||
* Protocol flow (h0011 = DATA_REQ_SP, h0014 = CONVOY):
|
||||
* 1. Enable notifications on h0011 + h0014
|
||||
* 2. Send CONVOY_INIT (00 1c …) to h0011, send ping (00 00 00) to h0014
|
||||
* 3. Wait for ping echo on h0014 (00 00 04 = ready; 00 01 04 = BUSY)
|
||||
* 4. Wait for h0011 echo (00 1c …) — watch loading flash (~8 s)
|
||||
* 5. cap_query / cap_set / init_sig / version exchange on h0014
|
||||
* 6. Request session list (feat 0x1d, addr 0x46a0) via h0011
|
||||
* 7. Collect type-0x05 CONVOY packets; wait for 0x09 DATA_READY on h0011
|
||||
* 8. For each session: request summary (feat 0x1e, addr), collect, parse, save
|
||||
* 9. Close with cancel (03 1c …) on h0011
|
||||
*/
|
||||
public class FetchSportDataOperation extends AbstractBTLEOperation<CasioGBD200DeviceSupport> {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(FetchSportDataOperation.class);
|
||||
|
||||
private static final int SESSION_LIST_BASE = 0x46a0;
|
||||
private static final int META_BLOCK_HDR = 15;
|
||||
private static final int META_LAP_STRIDE = 19;
|
||||
private static final int TIMEOUT_MS = 45_000;
|
||||
|
||||
private final Handler mTimeoutHandler = new Handler(Looper.getMainLooper());
|
||||
|
||||
// State machine
|
||||
private enum State {
|
||||
WAIT_PING_ECHO,
|
||||
WAIT_INIT_ECHO,
|
||||
WAIT_CAP_RESPONSE,
|
||||
WAIT_CAP_CONFIRM,
|
||||
WAIT_VERSION,
|
||||
COLLECTING_LIST,
|
||||
COLLECTING_SESSION,
|
||||
COLLECTING_META
|
||||
}
|
||||
|
||||
private State mState = State.WAIT_PING_ECHO;
|
||||
private final ArrayList<Byte> mConvoyBuf = new ArrayList<>();
|
||||
private int mTotalSessions = 0;
|
||||
private int mCurrentSession = 0; // 1-based
|
||||
private int mMetaAddress = 0;
|
||||
private int mSegCount = 0;
|
||||
private BaseActivitySummary mCurrentSummary = null;
|
||||
|
||||
private final CasioGBD200DeviceSupport mSupport;
|
||||
|
||||
public FetchSportDataOperation(CasioGBD200DeviceSupport support) {
|
||||
super(support);
|
||||
this.mSupport = support;
|
||||
}
|
||||
|
||||
// ── Write helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Write to DATA_REQUEST_SP (h0011) with WRITE_REQ. */
|
||||
private void writeH0011(byte[] data, String label) {
|
||||
try {
|
||||
TransactionBuilder b = performInitialized(label);
|
||||
b.setCallback(this);
|
||||
b.writeLegacy(
|
||||
getCharacteristic(CasioConstants.CASIO_DATA_REQUEST_SP_CHARACTERISTIC_UUID),
|
||||
data);
|
||||
b.queue();
|
||||
} catch (IOException e) {
|
||||
LOG.error("writeH0011 [{}] failed: {}", label, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Write to CONVOY (h0014) with WRITE_CMD (no response). */
|
||||
private void writeH0014(byte[] data, String label) {
|
||||
try {
|
||||
TransactionBuilder b = performInitialized(label);
|
||||
b.setCallback(this);
|
||||
b.writeLegacy(
|
||||
getCharacteristic(CasioConstants.CASIO_CONVOY_CHARACTERISTIC_UUID),
|
||||
data);
|
||||
b.queue();
|
||||
} catch (IOException e) {
|
||||
LOG.error("writeH0014 [{}] failed: {}", label, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** feat_req: [op=0x00] [feat] 0x00 [off_lo] [off_hi] 0x00 0x00 [param] 0x00 0x00 */
|
||||
private byte[] featReq(int feat, int offset, int param) {
|
||||
return new byte[]{
|
||||
0x00, (byte) feat, 0x00,
|
||||
(byte) (offset & 0xff), (byte) ((offset >> 8) & 0xff),
|
||||
0x00, 0x00, (byte) param, 0x00, 0x00
|
||||
};
|
||||
}
|
||||
|
||||
/** ack: [op=0x04] [feat] 0x00 … (10 bytes total) */
|
||||
private byte[] ackReq(int feat) {
|
||||
byte[] a = new byte[10];
|
||||
a[0] = 0x04;
|
||||
a[1] = (byte) feat;
|
||||
return a;
|
||||
}
|
||||
|
||||
/** Echo first 10 bytes of a DATA_READY (0x09) packet. */
|
||||
private byte[] echo10(byte[] pkt) {
|
||||
byte[] out = new byte[10];
|
||||
System.arraycopy(pkt, 0, out, 0, Math.min(pkt.length, 10));
|
||||
return out;
|
||||
}
|
||||
|
||||
private void enableNotifications(boolean enable) {
|
||||
try {
|
||||
TransactionBuilder b = performInitialized("enableSportNotif");
|
||||
b.setCallback(this);
|
||||
b.notify(CasioConstants.CASIO_DATA_REQUEST_SP_CHARACTERISTIC_UUID, enable);
|
||||
b.notify(CasioConstants.CASIO_CONVOY_CHARACTERISTIC_UUID, enable);
|
||||
b.queue();
|
||||
} catch (IOException e) {
|
||||
LOG.error("enableNotifications failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ── CONVOY data decoding ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Decode a type-0x05 CONVOY packet and append the payload to mConvoyBuf.
|
||||
* byte[0] (type) is unchanged; bytes[1:] are XOR'd with 0xFF.
|
||||
* The first 3 bytes of the decoded packet are the CONVOY header and are skipped.
|
||||
*/
|
||||
private void appendConvoyData(byte[] raw) {
|
||||
if (raw.length < 4) return;
|
||||
// raw[0] = 0x05 (type, unchanged); raw[1:] XOR'd with 0xFF.
|
||||
// The first 3 decoded bytes are a CONVOY header — skip them, accumulate payload only.
|
||||
for (int i = 3; i < raw.length; i++) {
|
||||
mConvoyBuf.add((byte) (~raw[i] & 0xff));
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] getConvoyPayload() {
|
||||
byte[] out = new byte[mConvoyBuf.size()];
|
||||
for (int i = 0; i < out.length; i++) out[i] = mConvoyBuf.get(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Session list parsing ──────────────────────────────────────────────────
|
||||
|
||||
private int parseSessionCount(byte[] payload) {
|
||||
if (payload.length <= 6) return 0;
|
||||
int raw = payload[6] & 0xff;
|
||||
int inv = (~raw) & 0xff;
|
||||
return Integer.bitCount(inv);
|
||||
}
|
||||
|
||||
// ── Session summary parsing and DB save ──────────────────────────────────
|
||||
|
||||
private int bcd(byte b) {
|
||||
return ((b >> 4) & 0x0f) * 10 + (b & 0x0f);
|
||||
}
|
||||
|
||||
/** Parse 7-byte BCD timestamp starting at payload[offset]. */
|
||||
private Date parseBcdTimestamp(byte[] p, int offset) {
|
||||
if (offset + 7 > p.length) return new Date(0);
|
||||
int yearLo = bcd(p[offset]);
|
||||
int yearHi = bcd(p[offset + 1]);
|
||||
int year = yearHi * 100 + yearLo;
|
||||
int month = bcd(p[offset + 2]) - 1; // 0-based
|
||||
int day = bcd(p[offset + 3]);
|
||||
int hour = bcd(p[offset + 4]);
|
||||
int min = bcd(p[offset + 5]);
|
||||
int sec = bcd(p[offset + 6]);
|
||||
Calendar cal = Calendar.getInstance();
|
||||
cal.set(year, month, day, hour, min, sec);
|
||||
cal.set(Calendar.MILLISECOND, 0);
|
||||
return cal.getTime();
|
||||
}
|
||||
|
||||
enum SaveResult { NEW, EXISTING, ERROR }
|
||||
|
||||
private SaveResult parseAndSaveSession(byte[] payload, int sessionIndex) {
|
||||
mCurrentSummary = null;
|
||||
if (payload.length < 186) {
|
||||
LOG.warn("Session {} payload too short: {} bytes", sessionIndex, payload.length);
|
||||
mMetaAddress = 0;
|
||||
mSegCount = 0;
|
||||
return SaveResult.ERROR;
|
||||
}
|
||||
|
||||
// All offsets are direct indices into payload[] (0-based)
|
||||
int durationMin = payload[174] & 0xff;
|
||||
int durationSec = payload[175] & 0xff;
|
||||
int avgPaceMin = payload[176] & 0xff;
|
||||
int avgPaceSec = payload[177] & 0xff;
|
||||
int kcal = payload[178] & 0xff;
|
||||
int cadence = payload[182] & 0xff;
|
||||
int segCount = payload[142] & 0xff;
|
||||
// Meta block address for per-lap data (feat 0x20)
|
||||
mMetaAddress = (payload[166] & 0xff) | ((payload[167] & 0xff) << 8);
|
||||
mSegCount = segCount;
|
||||
|
||||
float distKm = ByteBuffer.wrap(payload, 169, 4)
|
||||
.order(ByteOrder.LITTLE_ENDIAN)
|
||||
.getFloat();
|
||||
|
||||
Date startTime = parseBcdTimestamp(payload, 147);
|
||||
Date endTime = parseBcdTimestamp(payload, 154);
|
||||
|
||||
int totalSeconds = durationMin * 60 + durationSec;
|
||||
int avgPaceSeconds = avgPaceMin * 60 + avgPaceSec;
|
||||
|
||||
LOG.info("Session {}: duration={}m{}s dist={}km kcal={} pace={}'{} cad={}",
|
||||
sessionIndex, durationMin, durationSec, distKm, kcal, avgPaceMin, avgPaceSec, cadence);
|
||||
|
||||
ActivitySummaryData sd = new ActivitySummaryData();
|
||||
sd.add(ACTIVE_SECONDS, totalSeconds, UNIT_SECONDS);
|
||||
sd.add(DISTANCE_METERS, distKm * 1000.0f, UNIT_METERS);
|
||||
sd.add(CALORIES_BURNT, kcal, UNIT_KCAL);
|
||||
sd.add(CADENCE_AVG, cadence, UNIT_SPM);
|
||||
if (avgPaceSeconds > 0) {
|
||||
sd.add(PACE_AVG_SECONDS_KM, avgPaceSeconds, UNIT_SECONDS_PER_KM);
|
||||
}
|
||||
|
||||
BaseActivitySummary summary = new BaseActivitySummary();
|
||||
summary.setName(ActivityKind.RUNNING.getLabel(getContext()));
|
||||
summary.setStartTime(startTime.getTime() == 0 ? new Date() : startTime);
|
||||
summary.setEndTime(endTime.getTime() == 0 ? new Date(startTime.getTime() + totalSeconds * 1000L) : endTime);
|
||||
summary.setActivityKind(ActivityKind.RUNNING.getCode());
|
||||
summary.setSummaryData(sd.toString());
|
||||
|
||||
try (DBHandler dbHandler = GBApplication.acquireDB()) {
|
||||
DaoSession session = dbHandler.getDaoSession();
|
||||
Device device = DBHelper.getDevice(getDevice(), session);
|
||||
User user = DBHelper.getUser(session);
|
||||
summary.setDevice(device);
|
||||
summary.setUser(user);
|
||||
|
||||
// Avoid duplicates: reuse the existing row's ID if the same session was already saved
|
||||
java.util.List<BaseActivitySummary> existing = session.getBaseActivitySummaryDao()
|
||||
.queryBuilder()
|
||||
.where(
|
||||
BaseActivitySummaryDao.Properties.DeviceId.eq(device.getId()),
|
||||
BaseActivitySummaryDao.Properties.StartTime.eq(summary.getStartTime()))
|
||||
.list();
|
||||
if (!existing.isEmpty()) {
|
||||
// Preserve the existing row as-is (it already has lap data in summaryData)
|
||||
mCurrentSummary = existing.get(0);
|
||||
LOG.info("Sport session {} already in database — skipping overwrite", sessionIndex);
|
||||
return SaveResult.EXISTING;
|
||||
}
|
||||
|
||||
session.getBaseActivitySummaryDao().insertOrReplace(summary);
|
||||
mCurrentSummary = summary;
|
||||
LOG.info("Saved sport session {} to database (new)", sessionIndex);
|
||||
return SaveResult.NEW;
|
||||
} catch (Exception e) {
|
||||
GB.toast(getContext(), "Error saving sport session: " + e.getLocalizedMessage(),
|
||||
Toast.LENGTH_LONG, GB.ERROR, e);
|
||||
}
|
||||
return SaveResult.ERROR;
|
||||
}
|
||||
|
||||
private void parseAndSaveMetaLaps(byte[] metaPayload) {
|
||||
if (mCurrentSummary == null || mSegCount <= 0) return;
|
||||
|
||||
// appendConvoyData already strips the 3-byte CONVOY header; laps start at META_BLOCK_HDR
|
||||
int lapBase = META_BLOCK_HDR;
|
||||
|
||||
ActivitySummaryTableBuilder tableBuilder = new ActivitySummaryTableBuilder(
|
||||
GROUP_LAPS, "laps_header",
|
||||
Arrays.asList("workout_lap", "distanceMeters", "lap_time", "averagePace",
|
||||
"caloriesBurnt", "averageCadence"));
|
||||
|
||||
for (int s = 0; s < mSegCount; s++) {
|
||||
int base = lapBase + s * META_LAP_STRIDE;
|
||||
if (base + META_LAP_STRIDE > metaPayload.length) break;
|
||||
|
||||
float distKm = ByteBuffer.wrap(metaPayload, base, 4).order(ByteOrder.LITTLE_ENDIAN).getFloat();
|
||||
int durSec = (metaPayload[base + 8] & 0xff) * 60 + (metaPayload[base + 9] & 0xff);
|
||||
int paceSec = (metaPayload[base + 10] & 0xff) * 60 + (metaPayload[base + 11] & 0xff);
|
||||
int lapKcal = metaPayload[base + 12] & 0xff;
|
||||
int lapCad = metaPayload[base + 16] & 0xff;
|
||||
|
||||
tableBuilder.addRow("lap_" + (s + 1), Arrays.asList(
|
||||
new ActivitySummaryValue(s + 1, UNIT_NONE),
|
||||
new ActivitySummaryValue(distKm * 1000f, UNIT_METERS),
|
||||
new ActivitySummaryValue(durSec, UNIT_SECONDS),
|
||||
new ActivitySummaryValue(paceSec > 0 ? paceSec : null, UNIT_SECONDS_PER_KM),
|
||||
new ActivitySummaryValue(lapKcal > 0 ? lapKcal : null, UNIT_KCAL),
|
||||
new ActivitySummaryValue(lapCad > 0 ? lapCad : null, UNIT_SPM)
|
||||
));
|
||||
}
|
||||
|
||||
if (!tableBuilder.hasRows()) return;
|
||||
|
||||
ActivitySummaryData sd = ActivitySummaryData.fromJson(mCurrentSummary.getSummaryData());
|
||||
tableBuilder.addToSummaryData(sd);
|
||||
mCurrentSummary.setSummaryData(sd.toString());
|
||||
|
||||
try (DBHandler dbHandler = GBApplication.acquireDB()) {
|
||||
DaoSession session = dbHandler.getDaoSession();
|
||||
Device device = DBHelper.getDevice(getDevice(), session);
|
||||
User user = DBHelper.getUser(session);
|
||||
mCurrentSummary.setDevice(device);
|
||||
mCurrentSummary.setUser(user);
|
||||
session.getBaseActivitySummaryDao().insertOrReplace(mCurrentSummary);
|
||||
LOG.info("Updated session {} with {} laps", mCurrentSession, mSegCount);
|
||||
} catch (Exception e) {
|
||||
LOG.error("Error saving lap data: {}", e.getLocalizedMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Operation lifecycle ────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
protected void prePerform() throws IOException {
|
||||
super.prePerform();
|
||||
getDevice().setBusyTask(R.string.busy_task_fetch_activity_data, getContext());
|
||||
GB.updateTransferNotification(null,
|
||||
getContext().getString(R.string.busy_task_fetch_activity_data), true, 0,
|
||||
getContext());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doPerform() throws IOException {
|
||||
enableNotifications(true);
|
||||
mState = State.WAIT_PING_ECHO;
|
||||
mConvoyBuf.clear();
|
||||
|
||||
mTimeoutHandler.postDelayed(this::onTimeout, TIMEOUT_MS);
|
||||
|
||||
// CONVOY_INIT request to h0011
|
||||
writeH0011(featReq(0x1c, 0, 0), "convoy_init");
|
||||
// ping to h0014
|
||||
writeH0014(new byte[]{0x00, 0x00, 0x00}, "ping");
|
||||
}
|
||||
|
||||
private void onTimeout() {
|
||||
LOG.warn("FetchSportDataOperation timed out in state {}", mState);
|
||||
GB.toast(getContext(), getContext().getString(R.string.busy_task_fetch_activity_data)
|
||||
+ ": timeout", Toast.LENGTH_SHORT, GB.WARN);
|
||||
enableNotifications(false);
|
||||
operationFinished();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void operationFinished() {
|
||||
if (operationStatus == OperationStatus.FINISHED) return;
|
||||
mTimeoutHandler.removeCallbacksAndMessages(null);
|
||||
LOG.info("FetchSportDataOperation finished");
|
||||
unsetBusy();
|
||||
GB.updateTransferNotification(null,
|
||||
getContext().getString(R.string.busy_task_fetch_activity_data), false, 100,
|
||||
getContext());
|
||||
GB.signalActivityDataFinish(getDevice());
|
||||
|
||||
operationStatus = OperationStatus.FINISHED;
|
||||
if (getDevice() != null) {
|
||||
try {
|
||||
TransactionBuilder b = performInitialized("finished");
|
||||
b.setCallback(null);
|
||||
b.wait(0);
|
||||
b.queue();
|
||||
} catch (IOException ex) {
|
||||
LOG.error("Error resetting Gatt callback", ex);
|
||||
}
|
||||
}
|
||||
// Push any pending settings changes to the watch now that data fetch is done.
|
||||
// This replaces the syncProfile() call that used to run during init (which blocked
|
||||
// auto-fetch by marking the device "Configuring" before fetch could start).
|
||||
mSupport.syncProfile();
|
||||
}
|
||||
|
||||
private void proceedToNextSessionOrClose() {
|
||||
mCurrentSession++;
|
||||
if (mCurrentSession > mTotalSessions) {
|
||||
LOG.info("All sessions fetched");
|
||||
closeSession();
|
||||
} else {
|
||||
mConvoyBuf.clear();
|
||||
mState = State.COLLECTING_SESSION;
|
||||
int addr = SESSION_LIST_BASE + 0x40 + mCurrentSession;
|
||||
LOG.debug("Requesting session {}/{} @ 0x{}", mCurrentSession, mTotalSessions,
|
||||
Integer.toHexString(addr));
|
||||
int progress = 40 + (int) ((float) mCurrentSession / mTotalSessions * 55);
|
||||
GB.updateTransferNotification(null,
|
||||
getContext().getString(R.string.busy_task_fetch_activity_data),
|
||||
true, progress, getContext());
|
||||
writeH0011(featReq(0x1e, addr, 0x01), "session_request_" + mCurrentSession);
|
||||
}
|
||||
}
|
||||
|
||||
private void closeSession() {
|
||||
LOG.debug("Closing sport session");
|
||||
writeH0011(new byte[]{0x03, 0x1c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
|
||||
"close_convoy");
|
||||
enableNotifications(false);
|
||||
operationFinished();
|
||||
}
|
||||
|
||||
// ── Characteristic callbacks ────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public boolean onCharacteristicChanged(BluetoothGatt gatt,
|
||||
BluetoothGattCharacteristic characteristic,
|
||||
byte[] data) {
|
||||
UUID uuid = characteristic.getUuid();
|
||||
if (data == null || data.length == 0) return true;
|
||||
|
||||
if (uuid.equals(CasioConstants.CASIO_CONVOY_CHARACTERISTIC_UUID)) {
|
||||
return onConvoyChanged(data);
|
||||
} else if (uuid.equals(CasioConstants.CASIO_DATA_REQUEST_SP_CHARACTERISTIC_UUID)) {
|
||||
return onH0011Changed(data);
|
||||
} else if (uuid.equals(CasioConstants.CASIO_ALL_FEATURES_CHARACTERISTIC_UUID)) {
|
||||
// BUSY recovery: watch sends WATCH_COND with byte[7]==0x01 when ready after BUSY
|
||||
if (mState == State.WAIT_PING_ECHO && data[0] == 0x28
|
||||
&& data.length >= 8 && data[7] == 0x01) {
|
||||
LOG.debug("BUSY recovery: WATCH_COND ready");
|
||||
enableNotifications(false);
|
||||
enableNotifications(true);
|
||||
mConvoyBuf.clear();
|
||||
mState = State.WAIT_PING_ECHO;
|
||||
writeH0011(featReq(0x1c, 0, 0), "convoy_init_retry");
|
||||
writeH0014(new byte[]{0x00, 0x00, 0x00}, "ping_retry");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.onCharacteristicChanged(gatt, characteristic, data);
|
||||
}
|
||||
|
||||
/** Handle CONVOY (h0014) notifications. */
|
||||
private boolean onConvoyChanged(byte[] data) {
|
||||
byte type = data[0];
|
||||
|
||||
switch (mState) {
|
||||
case WAIT_PING_ECHO:
|
||||
if (type == 0x00) {
|
||||
if (data.length >= 2 && data[1] == 0x01) {
|
||||
// Watch is BUSY — cancel and wait for WATCH_COND ready
|
||||
LOG.warn("Watch BUSY — cancelling, waiting for WATCH_COND ready");
|
||||
writeH0014(new byte[]{0x03, 0x00}, "convoy_cancel_busy");
|
||||
writeH0011(new byte[]{0x03, 0x1c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
|
||||
"cancel_init_busy");
|
||||
// Stay in WAIT_PING_ECHO, handle WATCH_COND in ALL_FEATURES handler
|
||||
} else {
|
||||
// Ping echo received (00 00 04), now wait for h0011 init echo
|
||||
LOG.debug("Ping echo received, waiting for h0011 init echo");
|
||||
mState = State.WAIT_INIT_ECHO;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case WAIT_CAP_RESPONSE:
|
||||
if (type == 0x04) {
|
||||
LOG.debug("Cap response received, sending cap_set");
|
||||
mState = State.WAIT_CAP_CONFIRM;
|
||||
writeH0014(
|
||||
new byte[]{0x04, 0x01, 0x18, 0x00, 0x18, 0x00, 0x00, 0x00, (byte) 0xdc, 0x05},
|
||||
"cap_set");
|
||||
}
|
||||
break;
|
||||
|
||||
case WAIT_CAP_CONFIRM:
|
||||
if (type == 0x04) {
|
||||
LOG.debug("Cap confirm received, sending init_sig");
|
||||
mState = State.WAIT_VERSION;
|
||||
writeH0014(new byte[]{0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
|
||||
"init_sig");
|
||||
}
|
||||
break;
|
||||
|
||||
case WAIT_VERSION:
|
||||
if (type == 0x06) {
|
||||
LOG.debug("Version received, echoing back");
|
||||
writeH0014(data.clone(), "version_echo");
|
||||
// Immediately request session list
|
||||
mState = State.COLLECTING_LIST;
|
||||
mConvoyBuf.clear();
|
||||
GB.updateTransferNotification(null,
|
||||
getContext().getString(R.string.busy_task_fetch_activity_data),
|
||||
true, 20, getContext());
|
||||
writeH0011(featReq(0x1d, SESSION_LIST_BASE, 0x01), "list_request");
|
||||
}
|
||||
break;
|
||||
|
||||
case COLLECTING_LIST:
|
||||
if (type == 0x05) {
|
||||
appendConvoyData(data);
|
||||
}
|
||||
break;
|
||||
|
||||
case COLLECTING_SESSION:
|
||||
case COLLECTING_META:
|
||||
if (type == 0x05) {
|
||||
appendConvoyData(data);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Handle DATA_REQ_SP (h0011) notifications. */
|
||||
private boolean onH0011Changed(byte[] data) {
|
||||
if (data.length < 2) return true;
|
||||
|
||||
switch (mState) {
|
||||
case WAIT_INIT_ECHO:
|
||||
// Watch finished loading flash; now proceed with cap_query
|
||||
if (data[0] == 0x00 && data[1] == 0x1c) {
|
||||
LOG.debug("h0011 init echo received, sending cap_query");
|
||||
mState = State.WAIT_CAP_RESPONSE;
|
||||
writeH0014(new byte[]{0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
|
||||
"cap_query");
|
||||
}
|
||||
break;
|
||||
|
||||
case COLLECTING_LIST:
|
||||
// DATA_READY signal (0x09)
|
||||
if (data[0] == 0x09) {
|
||||
byte[] payload = getConvoyPayload();
|
||||
mTotalSessions = parseSessionCount(payload);
|
||||
LOG.info("Session list received: {} sessions", mTotalSessions);
|
||||
|
||||
writeH0011(echo10(data), "echo_list_signal");
|
||||
writeH0011(ackReq(0x1d), "ack_list");
|
||||
|
||||
if (mTotalSessions == 0) {
|
||||
LOG.info("No sessions to fetch");
|
||||
closeSession();
|
||||
} else {
|
||||
mCurrentSession = 1;
|
||||
mConvoyBuf.clear();
|
||||
mState = State.COLLECTING_SESSION;
|
||||
int addr = SESSION_LIST_BASE + 0x40 + mCurrentSession;
|
||||
LOG.debug("Requesting session {}/{} @ 0x{}", mCurrentSession, mTotalSessions,
|
||||
Integer.toHexString(addr));
|
||||
GB.updateTransferNotification(null,
|
||||
getContext().getString(R.string.busy_task_fetch_activity_data),
|
||||
true, 40, getContext());
|
||||
writeH0011(featReq(0x1e, addr, 0x01), "session_request_1");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case COLLECTING_SESSION:
|
||||
if (data[0] == 0x09) {
|
||||
byte[] payload = getConvoyPayload();
|
||||
LOG.debug("Session {}/{} payload: {} bytes", mCurrentSession, mTotalSessions,
|
||||
payload.length);
|
||||
SaveResult result = parseAndSaveSession(payload, mCurrentSession);
|
||||
|
||||
writeH0011(echo10(data), "echo_session_signal");
|
||||
writeH0011(ackReq(0x1e), "ack_session");
|
||||
|
||||
int progress = 40 + (int) ((float) mCurrentSession / mTotalSessions * 55);
|
||||
GB.updateTransferNotification(null,
|
||||
getContext().getString(R.string.busy_task_fetch_activity_data),
|
||||
true, progress, getContext());
|
||||
|
||||
if (result == SaveResult.EXISTING) {
|
||||
// Session already in DB — lap data is already saved, skip meta download.
|
||||
proceedToNextSessionOrClose();
|
||||
} else if (result == SaveResult.NEW) {
|
||||
if (mMetaAddress != 0 && mMetaAddress != 0xffff && mSegCount > 0) {
|
||||
mConvoyBuf.clear();
|
||||
mState = State.COLLECTING_META;
|
||||
LOG.debug("Requesting meta @ 0x{} for session {}", Integer.toHexString(mMetaAddress), mCurrentSession);
|
||||
writeH0011(featReq(0x20, mMetaAddress, 0x01), "meta_request_" + mCurrentSession);
|
||||
} else {
|
||||
proceedToNextSessionOrClose();
|
||||
}
|
||||
} else {
|
||||
proceedToNextSessionOrClose();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case COLLECTING_META:
|
||||
if (data[0] == 0x09 || data[0] == 0x07) {
|
||||
parseAndSaveMetaLaps(getConvoyPayload());
|
||||
writeH0011(echo10(data), "echo_meta_signal");
|
||||
writeH0011(ackReq(0x20), "ack_meta");
|
||||
proceedToNextSessionOrClose();
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
/* Copyright (C) 2026 Davide Gessa
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gbd200;
|
||||
|
||||
import android.bluetooth.BluetoothGatt;
|
||||
import android.bluetooth.BluetoothGattCharacteristic;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Calendar;
|
||||
import java.util.TimeZone;
|
||||
import java.util.UUID;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.R;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.casio.CasioConstants;
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.CasioGBX100ActivitySample;
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEOperation;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.miband.operations.OperationStatus;
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.GB;
|
||||
|
||||
public class FetchStepCountDataOperation extends AbstractBTLEOperation<CasioGBD200DeviceSupport> {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(FetchStepCountDataOperation.class);
|
||||
private static final int TIMEOUT_MS = 30_000;
|
||||
|
||||
private final CasioGBD200DeviceSupport support;
|
||||
private byte mLastWrittenCmd = 0x00;
|
||||
private final Handler mTimeoutHandler = new Handler(Looper.getMainLooper());
|
||||
|
||||
public FetchStepCountDataOperation(CasioGBD200DeviceSupport support) {
|
||||
super(support);
|
||||
this.support = support;
|
||||
}
|
||||
|
||||
private void enableRequiredNotifications(boolean enable) {
|
||||
try {
|
||||
TransactionBuilder builder = performInitialized("enableRequiredNotifications");
|
||||
builder.setCallback(this);
|
||||
builder.notify(CasioConstants.CASIO_DATA_REQUEST_SP_CHARACTERISTIC_UUID, enable);
|
||||
builder.notify(CasioConstants.CASIO_CONVOY_CHARACTERISTIC_UUID, enable);
|
||||
builder.queue();
|
||||
} catch (IOException e) {
|
||||
LOG.error("Error enabling required notifications", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void requestStepCountData() {
|
||||
mLastWrittenCmd = 0x00;
|
||||
try {
|
||||
TransactionBuilder builder = performInitialized("requestStepCountData");
|
||||
builder.setCallback(this);
|
||||
builder.writeLegacy(
|
||||
getCharacteristic(CasioConstants.CASIO_DATA_REQUEST_SP_CHARACTERISTIC_UUID),
|
||||
new byte[]{0x00, 0x11, 0x00, 0x00, 0x00});
|
||||
builder.queue();
|
||||
} catch (IOException e) {
|
||||
LOG.error("Error requesting step count data", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void writeStepCountAck() {
|
||||
mLastWrittenCmd = 0x04;
|
||||
try {
|
||||
TransactionBuilder builder = performInitialized("writeStepCountAck");
|
||||
builder.setCallback(this);
|
||||
builder.writeLegacy(
|
||||
getCharacteristic(CasioConstants.CASIO_DATA_REQUEST_SP_CHARACTERISTIC_UUID),
|
||||
new byte[]{0x04, 0x11, 0x00, 0x00, 0x00});
|
||||
builder.queue();
|
||||
} catch (IOException e) {
|
||||
LOG.error("Error writing step count ack — finishing anyway", e);
|
||||
enableRequiredNotifications(false);
|
||||
operationFinished();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void prePerform() throws IOException {
|
||||
super.prePerform();
|
||||
getDevice().setBusyTask(R.string.busy_task_fetch_steps, getContext());
|
||||
GB.updateTransferNotification(null,
|
||||
getContext().getString(R.string.busy_task_fetch_activity_data), true, 0,
|
||||
getContext());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doPerform() throws IOException {
|
||||
mTimeoutHandler.postDelayed(this::onTimeout, TIMEOUT_MS);
|
||||
enableRequiredNotifications(true);
|
||||
requestStepCountData();
|
||||
}
|
||||
|
||||
private void onTimeout() {
|
||||
LOG.warn("FetchStepCountDataOperation timed out");
|
||||
GB.toast(getContext(), getContext().getString(R.string.busy_task_fetch_activity_data)
|
||||
+ ": timeout", Toast.LENGTH_SHORT, GB.WARN);
|
||||
enableRequiredNotifications(false);
|
||||
operationFinished();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void operationFinished() {
|
||||
if (operationStatus == OperationStatus.FINISHED) return;
|
||||
mTimeoutHandler.removeCallbacksAndMessages(null);
|
||||
LOG.info("FetchStepCountDataOperation finished");
|
||||
unsetBusy();
|
||||
GB.updateTransferNotification(null,
|
||||
getContext().getString(R.string.busy_task_fetch_activity_data), false, 100,
|
||||
getContext());
|
||||
GB.signalActivityDataFinish(getDevice());
|
||||
|
||||
operationStatus = OperationStatus.FINISHED;
|
||||
if (getDevice() != null) {
|
||||
try {
|
||||
TransactionBuilder builder = performInitialized("finished");
|
||||
builder.setCallback(null);
|
||||
builder.wait(0);
|
||||
builder.queue();
|
||||
} catch (IOException ex) {
|
||||
LOG.error("Error resetting Gatt callback", ex);
|
||||
}
|
||||
}
|
||||
support.onStepCountFetchFinished();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCharacteristicChanged(BluetoothGatt gatt,
|
||||
BluetoothGattCharacteristic characteristic,
|
||||
byte[] data) {
|
||||
UUID characteristicUUID = characteristic.getUuid();
|
||||
|
||||
if (data == null || data.length == 0) return true;
|
||||
|
||||
if (characteristicUUID.equals(CasioConstants.CASIO_DATA_REQUEST_SP_CHARACTERISTIC_UUID)) {
|
||||
int length = 0;
|
||||
if (data.length > 3) {
|
||||
length = (data[2] & 0xff) | ((data[3] & 0xff) << 8);
|
||||
}
|
||||
LOG.debug("Step count response: {} bytes", length);
|
||||
GB.updateTransferNotification(null,
|
||||
getContext().getString(R.string.busy_task_fetch_activity_data), true, 10,
|
||||
getContext());
|
||||
return true;
|
||||
} else if (characteristicUUID.equals(CasioConstants.CASIO_CONVOY_CHARACTERISTIC_UUID)) {
|
||||
if (data.length < 18) {
|
||||
LOG.info("CONVOY data too short");
|
||||
} else {
|
||||
// XOR all bytes with 0xFF (step count encoding)
|
||||
for (int i = 0; i < data.length; i++) {
|
||||
data[i] = (byte) (~data[i]);
|
||||
}
|
||||
|
||||
int payloadLength = (data[0] & 0xff) | ((data[1] & 0xff) << 8);
|
||||
if (data.length != (payloadLength + 2)) {
|
||||
LOG.warn("Payload length mismatch: {} vs {}", payloadLength, data.length);
|
||||
}
|
||||
|
||||
Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
|
||||
ArrayList<CasioGBX100ActivitySample> stepCountData = new ArrayList<>();
|
||||
|
||||
int year = data[2];
|
||||
int month = data[3] - 1; // 0-based
|
||||
int day = data[4];
|
||||
int hour = data[5];
|
||||
int minute = data[6];
|
||||
|
||||
int stepCount = (data[7] & 0xff) | ((data[8] & 0xff) << 8)
|
||||
| ((data[9] & 0xff) << 16) | ((data[10] & 0xff) << 24);
|
||||
if (stepCount == 0xfffffffe) stepCount = 0;
|
||||
|
||||
int calories = (data[11] & 0xff) | ((data[12] & 0xff) << 8);
|
||||
if (calories == 0xfffe) calories = 0;
|
||||
|
||||
LOG.debug("Steps: {} Calories: {}", stepCount, calories);
|
||||
|
||||
cal.set(year + 2000, month, day, hour, 30, 0);
|
||||
int ts_to = (int) (cal.getTimeInMillis() / 1000);
|
||||
cal.set(year + 2000, month, day, 0, 0, 0);
|
||||
int ts_from = (int) (cal.getTimeInMillis() / 1000);
|
||||
|
||||
CasioGBX100ActivitySample sum = support.getSumWithinRange(ts_from, ts_to);
|
||||
int caloriesToday = sum.getCalories();
|
||||
int stepsToday = sum.getSteps();
|
||||
|
||||
cal.set(year + 2000, month, day, hour, 30, 0);
|
||||
|
||||
// Parse hourly history blocks (dtype 0x04=steps, 0x05=calories)
|
||||
if (data[17] == 0x00 && data.length > 18) {
|
||||
int index = 18;
|
||||
boolean inPkt = false;
|
||||
int pktIdx = 0;
|
||||
int pktLen = 0;
|
||||
int type = 0;
|
||||
|
||||
while (index < data.length) {
|
||||
if (!inPkt) {
|
||||
if (index + 3 > data.length) break;
|
||||
type = data[index];
|
||||
pktLen = (data[index + 1] & 0xff) | ((data[index + 2] & 0xff) << 8);
|
||||
pktIdx = 0;
|
||||
inPkt = true;
|
||||
index += 3;
|
||||
}
|
||||
if (index + 1 >= data.length) break;
|
||||
int count = (data[index] & 0xff) | ((data[index + 1] & 0xff) << 8);
|
||||
if (count == 0xfffe) count = 0;
|
||||
index += 2;
|
||||
|
||||
if (type == CasioConstants.CASIO_CONVOY_DATATYPE_STEPS) {
|
||||
cal.add(Calendar.HOUR, -1);
|
||||
int ts = (int) (cal.getTimeInMillis() / 1000);
|
||||
CasioGBX100ActivitySample sample = new CasioGBX100ActivitySample();
|
||||
sample.setSteps(count);
|
||||
sample.setTimestamp(ts);
|
||||
sample.setRawKind(ActivityKind.ACTIVITY.getCode());
|
||||
stepCountData.add(sample);
|
||||
if (ts > ts_from && ts < ts_to) stepsToday += count;
|
||||
} else if (type == CasioConstants.CASIO_CONVOY_DATATYPE_CALORIES) {
|
||||
int idx = pktIdx / 2;
|
||||
if (idx < stepCountData.size() && stepCountData.get(idx).getSteps() > 0) {
|
||||
stepCountData.get(idx).setCalories(count);
|
||||
int ts = stepCountData.get(idx).getTimestamp();
|
||||
if (ts > ts_from && ts < ts_to) caloriesToday += count;
|
||||
}
|
||||
}
|
||||
|
||||
pktIdx += 2;
|
||||
if (pktIdx >= pktLen) inPkt = false;
|
||||
}
|
||||
}
|
||||
|
||||
int remainSteps = stepCount - stepsToday;
|
||||
int remainCals = calories - caloriesToday;
|
||||
if (remainSteps > 0 && remainCals > 0) {
|
||||
cal.set(year + 2000, month, day, hour, 30, 0);
|
||||
int ts = (int) (cal.getTimeInMillis() / 1000);
|
||||
CasioGBX100ActivitySample sample = new CasioGBX100ActivitySample();
|
||||
sample.setSteps(remainSteps);
|
||||
sample.setCalories(remainCals);
|
||||
sample.setTimestamp(ts);
|
||||
sample.setRawKind(ActivityKind.ACTIVITY.getCode());
|
||||
stepCountData.add(0, sample);
|
||||
}
|
||||
|
||||
support.stepCountDataFetched(stepCount, calories, stepCountData);
|
||||
}
|
||||
|
||||
GB.updateTransferNotification(null,
|
||||
getContext().getString(R.string.busy_task_fetch_activity_data), true, 80,
|
||||
getContext());
|
||||
writeStepCountAck();
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.onCharacteristicChanged(gatt, characteristic, data);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCharacteristicWrite(BluetoothGatt gatt,
|
||||
BluetoothGattCharacteristic characteristic,
|
||||
int status) {
|
||||
UUID uuid = characteristic.getUuid();
|
||||
if (uuid.equals(CasioConstants.CASIO_DATA_REQUEST_SP_CHARACTERISTIC_UUID)) {
|
||||
if (mLastWrittenCmd == 0x00) {
|
||||
LOG.debug("Step count request sent");
|
||||
} else if (mLastWrittenCmd == 0x04) {
|
||||
LOG.debug("Step count ACK sent, finishing");
|
||||
enableRequiredNotifications(false);
|
||||
operationFinished();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return super.onCharacteristicWrite(gatt, characteristic, status);
|
||||
}
|
||||
}
|
||||
+442
@@ -0,0 +1,442 @@
|
||||
/* Copyright (C) 2026 Davide Gessa
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gbd200;
|
||||
|
||||
import android.bluetooth.BluetoothGatt;
|
||||
import android.bluetooth.BluetoothGattCharacteristic;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.casio.CasioConstants;
|
||||
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEOperation;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.casio.Casio2C2DSupport;
|
||||
|
||||
/**
|
||||
* Full init handshake for GBD-200, following the exact protocol sequence confirmed
|
||||
* from btsnoop HCI captures. The watch only shows "connection ok" after this completes.
|
||||
*
|
||||
* Sequence:
|
||||
* 1. Request APP_INFO (0x22) → write back APP_INFO (announces capabilities, incl. time-sync support)
|
||||
* 2. Request BLE_FEAT (0x10)
|
||||
* 3. Write WATCH_NAME to ALL_FEAT (identity confirm)
|
||||
* 4. Request MODULE_ID (0x26)
|
||||
* 5. WATCH_COND + VER_INFO x 2 rounds, then one final WATCH_COND
|
||||
* 6. Request DST_WATCH_STATE (0x1d) → echo back
|
||||
* 7. Request DST_SETTING slot 0 → save; request slot 1 → echo both
|
||||
* 8. Write GPS chunks (0x24)
|
||||
* 9. Request WORLD_CITY slot 0 → save; request slot 1 → echo both
|
||||
* 10. Request FEAT_2F (0x2f) → echo back
|
||||
* 11. Request USER_PROF (0x45) → echo back
|
||||
* 12. Write CURRENT_TIME (0x09)
|
||||
* 13. Wait for 47 01 (INITIALIZED)
|
||||
* 14. Post-init reads: WATCH_COND, BASIC (0x13), VER_INFO, WATCH_COND
|
||||
*/
|
||||
public class InitOperation extends AbstractBTLEOperation<CasioGBD200DeviceSupport> {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(InitOperation.class);
|
||||
|
||||
// Linear init state machine
|
||||
private static final int S_APP_INFO = 0;
|
||||
private static final int S_BLE_FEAT = 1;
|
||||
private static final int S_MODULE_ID = 2;
|
||||
private static final int S_WATCH_COND_1 = 3;
|
||||
private static final int S_VER_INFO_1 = 4;
|
||||
private static final int S_WATCH_COND_2 = 5;
|
||||
private static final int S_VER_INFO_2 = 6;
|
||||
private static final int S_WATCH_COND_3 = 7;
|
||||
private static final int S_DST_WATCH = 8;
|
||||
private static final int S_DST_SETTING_0 = 9;
|
||||
private static final int S_DST_SETTING_1 = 10;
|
||||
private static final int S_WORLD_CITY_0 = 11;
|
||||
private static final int S_WORLD_CITY_1 = 12;
|
||||
private static final int S_FEAT_2F = 13;
|
||||
private static final int S_USER_PROF = 14;
|
||||
private static final int S_WAIT_INIT = 15;
|
||||
private static final int S_POST_COND_1 = 16;
|
||||
private static final int S_POST_BASIC = 17;
|
||||
private static final int S_POST_VER_INFO = 18;
|
||||
private static final int S_POST_COND_2 = 19;
|
||||
|
||||
private int mState = S_APP_INFO;
|
||||
private byte[] mDstSetting0 = null;
|
||||
private byte[] mWorldCity0 = null;
|
||||
|
||||
private final TransactionBuilder mBuilder;
|
||||
private final CasioGBD200DeviceSupport mSupport;
|
||||
private final boolean mFirstConnect;
|
||||
private final boolean mNeedsGetConfiguration;
|
||||
|
||||
public InitOperation(CasioGBD200DeviceSupport support, TransactionBuilder builder,
|
||||
boolean firstConnect, boolean needsGetConfiguration) {
|
||||
super(support);
|
||||
this.mBuilder = builder;
|
||||
this.mSupport = support;
|
||||
this.mFirstConnect = firstConnect;
|
||||
this.mNeedsGetConfiguration = needsGetConfiguration;
|
||||
builder.setCallback(this);
|
||||
}
|
||||
|
||||
// ── Write helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
private void req(byte feat) {
|
||||
try {
|
||||
TransactionBuilder b = createTransactionBuilder("req_" + String.format("%02x", feat));
|
||||
b.setCallback(this);
|
||||
mSupport.writeAllFeaturesRequest(b, new byte[]{feat});
|
||||
b.queueImmediately();
|
||||
} catch (IOException e) {
|
||||
LOG.error("req failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void req(byte feat, byte slot) {
|
||||
try {
|
||||
TransactionBuilder b = createTransactionBuilder("req_" + String.format("%02x", feat));
|
||||
b.setCallback(this);
|
||||
mSupport.writeAllFeaturesRequest(b, new byte[]{feat, slot});
|
||||
b.queueImmediately();
|
||||
} catch (IOException e) {
|
||||
LOG.error("req slot failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void write(byte[] data) {
|
||||
try {
|
||||
TransactionBuilder b = createTransactionBuilder("write_" + String.format("%02x", data[0]));
|
||||
b.setCallback(this);
|
||||
mSupport.writeAllFeatures(b, data);
|
||||
b.queueImmediately();
|
||||
} catch (IOException e) {
|
||||
LOG.error("write failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ── App information ──────────────────────────────────────────────────────
|
||||
|
||||
private void writeAppInformation() {
|
||||
// Announce this app's capabilities to the watch. arr[11] = 2 is required for
|
||||
// the watch to show "Bluetooth time adjustment: supported" in its menu.
|
||||
byte[] arr = new byte[12];
|
||||
arr[0] = Casio2C2DSupport.FEATURE_APP_INFORMATION;
|
||||
for (int i = 0; i < 10; i++) arr[i + 1] = (byte) (i & 0xff);
|
||||
arr[11] = 2;
|
||||
write(arr);
|
||||
}
|
||||
|
||||
// ── GPS encoding ─────────────────────────────────────────────────────────
|
||||
|
||||
private byte[] makeGpsChunk0(double lat, double lon) {
|
||||
ByteBuffer buf = ByteBuffer.allocate(20).order(ByteOrder.BIG_ENDIAN);
|
||||
buf.put((byte) 0x24);
|
||||
buf.put((byte) 0x00);
|
||||
buf.put((byte) 0x01);
|
||||
buf.putDouble(lat);
|
||||
buf.putDouble(lon);
|
||||
buf.put((byte) 0x04);
|
||||
return buf.array();
|
||||
}
|
||||
|
||||
private byte[] makeGpsChunk1(double alt) {
|
||||
ByteBuffer buf = ByteBuffer.allocate(20).order(ByteOrder.BIG_ENDIAN);
|
||||
buf.put((byte) 0x24);
|
||||
buf.put((byte) 0x01);
|
||||
buf.put((byte) 0x01);
|
||||
buf.putDouble(alt);
|
||||
// remaining 9 bytes are 0x00 (ByteBuffer zero-initialized)
|
||||
return buf.array();
|
||||
}
|
||||
|
||||
// ── Time encoding (DOW: Sunday=0, Monday=1 … Saturday=6) ────────────────
|
||||
|
||||
private void writeCurrentTime() {
|
||||
ZonedDateTime now = ZonedDateTime.now();
|
||||
// DOW: Sunday=0 … Saturday=6; Java DayOfWeek: Mon=1…Sun=7
|
||||
int dow = now.getDayOfWeek() == DayOfWeek.SUNDAY ? 0 : now.getDayOfWeek().getValue();
|
||||
byte[] arr = new byte[11];
|
||||
arr[0] = Casio2C2DSupport.FEATURE_CURRENT_TIME;
|
||||
arr[1] = (byte) (now.getYear() & 0xff);
|
||||
arr[2] = (byte) ((now.getYear() >> 8) & 0xff);
|
||||
arr[3] = (byte) now.getMonthValue();
|
||||
arr[4] = (byte) now.getDayOfMonth();
|
||||
arr[5] = (byte) now.getHour();
|
||||
arr[6] = (byte) now.getMinute();
|
||||
arr[7] = (byte) now.getSecond();
|
||||
arr[8] = (byte) dow;
|
||||
arr[9] = 0x00; // fractions256
|
||||
arr[10] = 0x01; // reason = manual sync
|
||||
write(arr);
|
||||
}
|
||||
|
||||
// ── AbstractBTLEOperation ────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
protected void doPerform() throws IOException {
|
||||
mBuilder.setDeviceState(GBDevice.State.INITIALIZING);
|
||||
mBuilder.notify(CasioConstants.CASIO_ALL_FEATURES_CHARACTERISTIC_UUID, true);
|
||||
mBuilder.writeLegacy(
|
||||
getCharacteristic(CasioConstants.CASIO_READ_REQUEST_FOR_ALL_FEATURES_CHARACTERISTIC_UUID),
|
||||
new byte[]{Casio2C2DSupport.FEATURE_APP_INFORMATION});
|
||||
}
|
||||
|
||||
@Override
|
||||
public TransactionBuilder performInitialized(String taskName) throws IOException {
|
||||
throw new UnsupportedOperationException("This IS the initialization class");
|
||||
}
|
||||
|
||||
// ── Main state machine ───────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public boolean onCharacteristicChanged(BluetoothGatt gatt,
|
||||
BluetoothGattCharacteristic characteristic,
|
||||
byte[] data) {
|
||||
UUID uuid = characteristic.getUuid();
|
||||
|
||||
if (data == null || data.length == 0) return true;
|
||||
|
||||
if (!uuid.equals(CasioConstants.CASIO_ALL_FEATURES_CHARACTERISTIC_UUID)) {
|
||||
return super.onCharacteristicChanged(gatt, characteristic, data);
|
||||
}
|
||||
|
||||
byte feat = data[0];
|
||||
|
||||
switch (mState) {
|
||||
case S_APP_INFO:
|
||||
if (feat == Casio2C2DSupport.FEATURE_APP_INFORMATION) {
|
||||
LOG.debug("Init[APP_INFO] → writing APP_INFO (capabilities), requesting BLE_FEAT");
|
||||
// Write back our app information so the watch knows this app supports
|
||||
// Bluetooth time adjustment (arr[11] = 2 is the capabilities byte).
|
||||
writeAppInformation();
|
||||
mState = S_BLE_FEAT;
|
||||
req(Casio2C2DSupport.FEATURE_BLE_FEATURES);
|
||||
}
|
||||
break;
|
||||
|
||||
case S_BLE_FEAT:
|
||||
if (feat == Casio2C2DSupport.FEATURE_BLE_FEATURES) {
|
||||
LOG.debug("Init[BLE_FEAT] → writing WATCH_NAME, requesting MODULE_ID");
|
||||
// Identity confirm: write device name to ALL_FEAT
|
||||
byte[] nameBytes = "CASIO GBD-200\0\0\0\0\0\0".getBytes(StandardCharsets.US_ASCII);
|
||||
byte[] namePacket = new byte[1 + nameBytes.length];
|
||||
namePacket[0] = Casio2C2DSupport.FEATURE_WATCH_NAME;
|
||||
System.arraycopy(nameBytes, 0, namePacket, 1, nameBytes.length);
|
||||
write(namePacket);
|
||||
mState = S_MODULE_ID;
|
||||
req(Casio2C2DSupport.FEATURE_MODULE_ID);
|
||||
}
|
||||
break;
|
||||
|
||||
case S_MODULE_ID:
|
||||
if (feat == Casio2C2DSupport.FEATURE_MODULE_ID) {
|
||||
LOG.debug("Init[MODULE_ID] → requesting WATCH_COND (round 1)");
|
||||
mState = S_WATCH_COND_1;
|
||||
req(Casio2C2DSupport.FEATURE_WATCH_CONDITION);
|
||||
}
|
||||
break;
|
||||
|
||||
case S_WATCH_COND_1:
|
||||
if (feat == Casio2C2DSupport.FEATURE_WATCH_CONDITION) {
|
||||
mState = S_VER_INFO_1;
|
||||
req(Casio2C2DSupport.FEATURE_VERSION_INFORMATION);
|
||||
}
|
||||
break;
|
||||
|
||||
case S_VER_INFO_1:
|
||||
if (feat == Casio2C2DSupport.FEATURE_VERSION_INFORMATION) {
|
||||
mState = S_WATCH_COND_2;
|
||||
req(Casio2C2DSupport.FEATURE_WATCH_CONDITION);
|
||||
}
|
||||
break;
|
||||
|
||||
case S_WATCH_COND_2:
|
||||
if (feat == Casio2C2DSupport.FEATURE_WATCH_CONDITION) {
|
||||
mState = S_VER_INFO_2;
|
||||
req(Casio2C2DSupport.FEATURE_VERSION_INFORMATION);
|
||||
}
|
||||
break;
|
||||
|
||||
case S_VER_INFO_2:
|
||||
if (feat == Casio2C2DSupport.FEATURE_VERSION_INFORMATION) {
|
||||
LOG.debug("Init[VER_INFO_2] → requesting final WATCH_COND");
|
||||
mState = S_WATCH_COND_3;
|
||||
req(Casio2C2DSupport.FEATURE_WATCH_CONDITION);
|
||||
}
|
||||
break;
|
||||
|
||||
case S_WATCH_COND_3:
|
||||
if (feat == Casio2C2DSupport.FEATURE_WATCH_CONDITION) {
|
||||
LOG.debug("Init[WATCH_COND_3] → requesting DST_WATCH_STATE");
|
||||
mState = S_DST_WATCH;
|
||||
req(Casio2C2DSupport.FEATURE_DST_WATCH_STATE);
|
||||
}
|
||||
break;
|
||||
|
||||
case S_DST_WATCH:
|
||||
if (feat == Casio2C2DSupport.FEATURE_DST_WATCH_STATE) {
|
||||
LOG.debug("Init[DST_WATCH] → echo + request DST_SETTING slot 0");
|
||||
write(data.clone()); // echo back
|
||||
mState = S_DST_SETTING_0;
|
||||
req(Casio2C2DSupport.FEATURE_DST_SETTING, (byte) 0x00);
|
||||
}
|
||||
break;
|
||||
|
||||
case S_DST_SETTING_0:
|
||||
if (feat == Casio2C2DSupport.FEATURE_DST_SETTING) {
|
||||
LOG.debug("Init[DST_SETTING_0] → saved, requesting slot 1");
|
||||
mDstSetting0 = data.clone();
|
||||
mState = S_DST_SETTING_1;
|
||||
req(Casio2C2DSupport.FEATURE_DST_SETTING, (byte) 0x01);
|
||||
}
|
||||
break;
|
||||
|
||||
case S_DST_SETTING_1:
|
||||
if (feat == Casio2C2DSupport.FEATURE_DST_SETTING) {
|
||||
LOG.debug("Init[DST_SETTING_1] → echo both + GPS + request WORLD_CITY slot 0");
|
||||
write(mDstSetting0); // echo slot 0
|
||||
write(data.clone()); // echo slot 1
|
||||
// GPS location
|
||||
double[] gps = mSupport.getLastKnownLocation();
|
||||
write(makeGpsChunk0(gps[0], gps[1]));
|
||||
write(makeGpsChunk1(gps[2]));
|
||||
mState = S_WORLD_CITY_0;
|
||||
req(Casio2C2DSupport.FEATURE_WORLD_CITY, (byte) 0x00);
|
||||
}
|
||||
break;
|
||||
|
||||
case S_WORLD_CITY_0:
|
||||
if (feat == Casio2C2DSupport.FEATURE_WORLD_CITY) {
|
||||
LOG.debug("Init[WORLD_CITY_0] → saved, requesting slot 1");
|
||||
mWorldCity0 = data.clone();
|
||||
mState = S_WORLD_CITY_1;
|
||||
req(Casio2C2DSupport.FEATURE_WORLD_CITY, (byte) 0x01);
|
||||
}
|
||||
break;
|
||||
|
||||
case S_WORLD_CITY_1:
|
||||
if (feat == Casio2C2DSupport.FEATURE_WORLD_CITY) {
|
||||
LOG.debug("Init[WORLD_CITY_1] → echo both + request FEAT_2F");
|
||||
write(mWorldCity0); // echo slot 0
|
||||
write(data.clone()); // echo slot 1
|
||||
mState = S_FEAT_2F;
|
||||
req(Casio2C2DSupport.FEATURE_FEAT_2F);
|
||||
}
|
||||
break;
|
||||
|
||||
case S_FEAT_2F:
|
||||
if (feat == Casio2C2DSupport.FEATURE_FEAT_2F) {
|
||||
LOG.debug("Init[FEAT_2F] → echo + request USER_PROF");
|
||||
write(data.clone());
|
||||
mState = S_USER_PROF;
|
||||
req(Casio2C2DSupport.FEATURE_SETTING_FOR_USER_PROFILE);
|
||||
}
|
||||
break;
|
||||
|
||||
case S_USER_PROF:
|
||||
if (feat == Casio2C2DSupport.FEATURE_SETTING_FOR_USER_PROFILE) {
|
||||
LOG.debug("Init[USER_PROF] → echo + write CURRENT_TIME, waiting for 47 01");
|
||||
write(data.clone());
|
||||
writeCurrentTime();
|
||||
mState = S_WAIT_INIT;
|
||||
}
|
||||
break;
|
||||
|
||||
case S_WAIT_INIT:
|
||||
if (feat == Casio2C2DSupport.FEATURE_SERVICE_DISCOVERY_MANAGER
|
||||
&& data.length > 1 && data[1] == 0x01) {
|
||||
LOG.info("Init[INITIALIZED] 47 01 received → post-init reads");
|
||||
mSupport.setInitialized();
|
||||
mState = S_POST_COND_1;
|
||||
req(Casio2C2DSupport.FEATURE_WATCH_CONDITION);
|
||||
} else if (feat == Casio2C2DSupport.FEATURE_SERVICE_DISCOVERY_MANAGER
|
||||
&& data.length > 1 && data[1] == 0x02) {
|
||||
// First-time pairing bonding request
|
||||
LOG.debug("Init[SVC_DISC 0x02] first-time bond request → write time + init");
|
||||
writeCurrentTime();
|
||||
byte[] initPkt = {0x00, 0x01};
|
||||
write(initPkt);
|
||||
}
|
||||
break;
|
||||
|
||||
case S_POST_COND_1:
|
||||
if (feat == Casio2C2DSupport.FEATURE_WATCH_CONDITION) {
|
||||
mState = S_POST_BASIC;
|
||||
req(Casio2C2DSupport.FEATURE_SETTING_FOR_BASIC);
|
||||
}
|
||||
break;
|
||||
|
||||
case S_POST_BASIC:
|
||||
if (feat == Casio2C2DSupport.FEATURE_SETTING_FOR_BASIC) {
|
||||
mState = S_POST_VER_INFO;
|
||||
req(Casio2C2DSupport.FEATURE_VERSION_INFORMATION);
|
||||
}
|
||||
break;
|
||||
|
||||
case S_POST_VER_INFO:
|
||||
if (feat == Casio2C2DSupport.FEATURE_VERSION_INFORMATION) {
|
||||
mState = S_POST_COND_2;
|
||||
req(Casio2C2DSupport.FEATURE_WATCH_CONDITION);
|
||||
}
|
||||
break;
|
||||
|
||||
case S_POST_COND_2:
|
||||
if (feat == Casio2C2DSupport.FEATURE_WATCH_CONDITION) {
|
||||
LOG.info("Init complete.");
|
||||
try {
|
||||
TransactionBuilder b = createTransactionBuilder("init_done");
|
||||
b.setCallback(null);
|
||||
b.wait(0);
|
||||
b.queueImmediately();
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to release GATT callback after init: {}", e.getMessage());
|
||||
}
|
||||
if (mFirstConnect) {
|
||||
mSupport.disconnect();
|
||||
mSupport.reconnectDelayed();
|
||||
} else if (mNeedsGetConfiguration) {
|
||||
// First reconnect after pairing: read config from watch, then
|
||||
// GetConfigurationOperation will call syncProfile() when done.
|
||||
mSupport.onReadConfiguration(null);
|
||||
}
|
||||
// Regular reconnects: skip syncProfile() here so auto-fetch is not
|
||||
// blocked by a "Configuring" busy state. Settings changed while
|
||||
// disconnected are pushed via onSharedPreferenceChanged.
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCharacteristicRead(BluetoothGatt gatt,
|
||||
BluetoothGattCharacteristic characteristic,
|
||||
byte[] value, int status) {
|
||||
return super.onCharacteristicRead(gatt, characteristic, value, status);
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -40,12 +40,12 @@ import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.Dev
|
||||
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_OPERATING_SOUNDS;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_WEARLOCATION;
|
||||
|
||||
public class GetConfigurationOperation extends AbstractBTLEOperation<CasioGBX100DeviceSupport> {
|
||||
public class GetConfigurationOperation extends AbstractBTLEOperation<Casio2C2DSupport> {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(GetConfigurationOperation.class);
|
||||
private final CasioGBX100DeviceSupport support;
|
||||
private final Casio2C2DSupport support;
|
||||
private final boolean mFirstConnect;
|
||||
|
||||
public GetConfigurationOperation(CasioGBX100DeviceSupport support, boolean firstconnect) {
|
||||
public GetConfigurationOperation(Casio2C2DSupport support, boolean firstconnect) {
|
||||
super(support);
|
||||
this.support = support;
|
||||
this.mFirstConnect = firstconnect;
|
||||
|
||||
+3
-3
@@ -44,12 +44,12 @@ import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.Dev
|
||||
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_WEARLOCATION;
|
||||
import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.GENDER_MALE;
|
||||
|
||||
public class SetConfigurationOperation extends AbstractBTLEOperation<CasioGBX100DeviceSupport> {
|
||||
public class SetConfigurationOperation extends AbstractBTLEOperation<Casio2C2DSupport> {
|
||||
private static final Logger LOG = LoggerFactory.getLogger(SetConfigurationOperation.class);
|
||||
private final CasioGBX100DeviceSupport support;
|
||||
private final Casio2C2DSupport support;
|
||||
private final CasioConstants.ConfigurationOption option;
|
||||
|
||||
public SetConfigurationOperation(CasioGBX100DeviceSupport support, CasioConstants.ConfigurationOption option) {
|
||||
public SetConfigurationOperation(Casio2C2DSupport support, CasioConstants.ConfigurationOption option) {
|
||||
super(support);
|
||||
this.support = support;
|
||||
this.option = option;
|
||||
|
||||
@@ -2095,6 +2095,7 @@
|
||||
<string name="devicetype_casiogbx100" translatable="false">Casio GBX-100</string>
|
||||
<string name="devicetype_casiogwb5600" translatable="false">Casio GW-B5600</string>
|
||||
<string name="devicetype_casiogmwb5000" translatable="false">Casio GMW-B5000</string>
|
||||
<string name="devicetype_casiogbd200" translatable="false">Casio GBD-200</string>
|
||||
<string name="devicetype_mismartscale" translatable="false">Mi Smart Scale 2</string>
|
||||
<string name="devicetype_micompositionscale" translatable="false">Mi Body Composition Scale 2</string>
|
||||
<string name="devicetype_itag" translatable="false">iTag</string>
|
||||
|
||||
Reference in New Issue
Block a user