From 4a9fd49461677924e5b6a63495a3903ca2fdf75e Mon Sep 17 00:00:00 2001 From: Johannes Krude Date: Fri, 4 Aug 2023 23:22:57 +0200 Subject: [PATCH] Casio GW-B5600: response handlers --- .../service/btle/TransactionBuilder.java | 8 +- .../service/btle/actions/FunctionAction.java | 47 ++++++ .../devices/casio/Casio2C2DSupport.java | 140 ++++++++++++++++++ .../gwb5600/CasioGWB5600DeviceSupport.java | 58 ++++++-- .../casio/gwb5600/CasioGWB5600TimeZone.java | 31 ++-- .../devices/casio/gwb5600/InitOperation.java | 130 ---------------- 6 files changed, 255 insertions(+), 159 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/actions/FunctionAction.java delete mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/gwb5600/InitOperation.java diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/TransactionBuilder.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/TransactionBuilder.java index 07f10e194..ffd2be395 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/TransactionBuilder.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/TransactionBuilder.java @@ -1,6 +1,6 @@ /* Copyright (C) 2015-2024 Andreas Böhler, Andreas Shimokawa, Carsten Pfeiffer, Damien Gaignon, Daniel Dakhno, Daniele Gobbetti, Frank Ertl, - José Rebelo + José Rebelo, Johannes Krude This file is part of Gadgetbridge. @@ -30,6 +30,7 @@ import androidx.annotation.RequiresApi; import java.util.Arrays; import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.BondAction; +import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.FunctionAction; import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.NotifyAction; import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.ReadAction; import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.RequestConnectionPriorityAction; @@ -118,6 +119,11 @@ public class TransactionBuilder { return add(action); } + // Runs the given function/lambda + public TransactionBuilder run(FunctionAction.Function function) { + return add(new FunctionAction(function)); + } + public TransactionBuilder add(BtLEAction action) { mTransaction.add(action); return this; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/actions/FunctionAction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/actions/FunctionAction.java new file mode 100644 index 000000000..404c64985 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/actions/FunctionAction.java @@ -0,0 +1,47 @@ +/* Copyright (C) 2023 Johannes Krude + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.service.btle.actions; + +import android.bluetooth.BluetoothGatt; + +import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.PlainAction; + +/** + * Invokes the given function + */ +public class FunctionAction extends PlainAction { + + public interface Function { + public void apply(BluetoothGatt gatt); + } + private Function function; + + public FunctionAction(Function function) { + this.function = function; + } + + @Override + public boolean run(BluetoothGatt gatt) { + function.apply(gatt); + return true; + } + + @Override + public boolean expectsResult() { + return false; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/Casio2C2DSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/Casio2C2DSupport.java index 29ad65065..db0e2aa97 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/Casio2C2DSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/Casio2C2DSupport.java @@ -17,11 +17,22 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.casio; import java.time.ZonedDateTime; +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCharacteristic; + import java.util.UUID; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.Iterator; +import java.util.Set; +import java.util.HashSet; +import java.util.Map; +import java.util.HashMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import nodomain.freeyourgadget.gadgetbridge.Logging; import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; import nodomain.freeyourgadget.gadgetbridge.devices.casio.CasioConstants; @@ -52,12 +63,24 @@ public abstract class Casio2C2DSupport extends CasioSupport { public static final byte FEATURE_SETTING_FOR_USER_PROFILE = 0x45; public static final byte FEATURE_SERVICE_DISCOVERY_MANAGER = 0x47; + private static Logger LOG; + LinkedList requests = new LinkedList<>(); public Casio2C2DSupport(Logger logger) { super(logger); + LOG = logger; + } + + @Override + public boolean connect() { + requests.clear(); + return super.connect(); } public void writeAllFeatures(TransactionBuilder builder, byte[] arr) { + if (!requests.isEmpty()) { + LOG.warn("writing while waiting for a response may lead to incorrect received responses"); + } builder.write(getCharacteristic(CasioConstants.CASIO_ALL_FEATURES_CHARACTERISTIC_UUID), arr); } @@ -65,6 +88,123 @@ public abstract class Casio2C2DSupport extends CasioSupport { builder.write(getCharacteristic(CasioConstants.CASIO_READ_REQUEST_FOR_ALL_FEATURES_CHARACTERISTIC_UUID), arr); } + public interface ResponseHandler { + void handle(byte[] response); + } + + public interface ResponsesHandler { + void handle(Map responses); + } + + public static class FeatureRequest { + byte data[]; + + public FeatureRequest(byte arg0) { + data = new byte[] {arg0}; + } + + public FeatureRequest(byte arg0, byte arg1) { + data = new byte[] {arg0, arg1}; + } + + public byte[] getData() { + return data.clone(); + } + + @Override + public int hashCode() { + return Arrays.hashCode(data); + } + + @Override + public boolean equals(Object o) { + if (o == this) + return true; + if (!(o instanceof FeatureRequest)) + return false; + + FeatureRequest fr = (FeatureRequest) o; + return Arrays.equals(data, fr.data); + } + + public boolean matches(byte[] response) { + if (response.length > 2 && response[0] == 0xFF && response[1] == 0x81) { + if (data.length < response.length - 2) + return false; + for (int i = 2; i < response.length; i++) { + if (response[i] != data[i-2]) + return false; + } + return true; + } else { + if (response.length < data.length) + return false; + for (int i = 0; i < data.length; i++) { + if (response[i] != data[i]) + return false; + } + return true; + } + } + } + + private static class RequestWithHandler { + public FeatureRequest request; + public ResponseHandler handler; + + public RequestWithHandler(FeatureRequest request, ResponseHandler handler) { + this.request = request; + this.handler = handler; + } + } + + public void requestFeature(TransactionBuilder builder, FeatureRequest request, ResponseHandler handler) { + builder.notify(getCharacteristic(CasioConstants.CASIO_ALL_FEATURES_CHARACTERISTIC_UUID), true); + writeAllFeaturesRequest(builder, request.getData()); + builder.run((gatt) -> requests.add(new RequestWithHandler(request, handler))); + } + + public void requestFeatures(TransactionBuilder builder, Set requests, ResponsesHandler handler) { + HashMap responses = new HashMap(); + + HashSet missing = new HashSet(); + for (FeatureRequest request: requests) { + missing.add(request); + } + + for (FeatureRequest request: requests) { + requestFeature(builder, request, data -> { + responses.put(request, data); + missing.remove(request); + if (missing.isEmpty()) { + handler.handle(responses); + } + }); + } + } + + @Override + public boolean onCharacteristicChanged(BluetoothGatt gatt, + BluetoothGattCharacteristic characteristic) { + UUID characteristicUUID = characteristic.getUuid(); + + if (characteristicUUID.equals(CasioConstants.CASIO_ALL_FEATURES_CHARACTERISTIC_UUID)) { + byte[] response = characteristic.getValue(); + Iterator it = requests.iterator(); + while (it.hasNext()) { + RequestWithHandler rh = it.next(); + if (rh.request.matches(response)) { + it.remove(); + rh.handler.handle(response); + return true; + } + } + LOG.warn("unhandled response: " + Logging.formatBytes(response)); + } + + return super.onCharacteristicChanged(gatt, characteristic); + } + public void writeCurrentTime(TransactionBuilder builder, ZonedDateTime time) { byte[] arr = new byte[11]; arr[0] = FEATURE_CURRENT_TIME; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/gwb5600/CasioGWB5600DeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/gwb5600/CasioGWB5600DeviceSupport.java index f980c1432..9f80b2bf9 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/gwb5600/CasioGWB5600DeviceSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/gwb5600/CasioGWB5600DeviceSupport.java @@ -17,19 +17,26 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gwb5600; import java.util.UUID; -import java.io.IOException; - -import android.widget.Toast; +import java.util.HashSet; +import java.util.Map; +import java.util.Locale; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.TextStyle; + import nodomain.freeyourgadget.gadgetbridge.util.GB; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction; import nodomain.freeyourgadget.gadgetbridge.devices.casio.CasioConstants; import nodomain.freeyourgadget.gadgetbridge.service.devices.casio.Casio2C2DSupport; -import nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gwb5600.InitOperation; +import nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gwb5600.CasioGWB5600TimeZone; public class CasioGWB5600DeviceSupport extends Casio2C2DSupport { private static final Logger LOG = LoggerFactory.getLogger(CasioGWB5600DeviceSupport.class); @@ -61,13 +68,46 @@ public class CasioGWB5600DeviceSupport extends Casio2C2DSupport { connect(); return builder; } - try { - new InitOperation(this, builder).perform(); - } catch (IOException e) { - GB.toast(getContext(), "Initializing watch failed", Toast.LENGTH_SHORT, GB.ERROR, e); - } + + builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext())); + requestWorldClocks(builder); return builder; } + private void requestWorldClocks(TransactionBuilder builder) { + HashSet requests = new HashSet(); + + for (byte i = 0; i < 6; i++) { + requests.addAll(CasioGWB5600TimeZone.requests(i)); + } + + requestFeatures(builder, requests, responses -> { + TransactionBuilder clockBuilder = createTransactionBuilder("setClocks"); + setClocks(clockBuilder, responses); + clockBuilder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZED, getContext())); + clockBuilder.queue(getQueue()); + }); + } + + private void setClocks(TransactionBuilder builder, Map responses) { + ZoneId tz = ZoneId.systemDefault(); + Instant now = Instant.now().plusSeconds(2); + CasioGWB5600TimeZone[] timezones = { + CasioGWB5600TimeZone.fromZoneId(tz, now, tz.getDisplayName(TextStyle.SHORT, Locale.getDefault())), + CasioGWB5600TimeZone.fromWatchResponses(responses, 1), + CasioGWB5600TimeZone.fromWatchResponses(responses, 2), + CasioGWB5600TimeZone.fromWatchResponses(responses, 3), + CasioGWB5600TimeZone.fromWatchResponses(responses, 4), + CasioGWB5600TimeZone.fromWatchResponses(responses, 5), + }; + for (int i = 5; i >= 0; i--) { + if (i%2 == 0) + writeAllFeatures(builder, CasioGWB5600TimeZone.dstWatchStateBytes(i, timezones[i], i+1, timezones[i+1])); + writeAllFeatures(builder, timezones[i].dstSettingBytes(i)); + writeAllFeatures(builder, timezones[i].worldCityBytes(i)); + } + writeCurrentTime(builder, ZonedDateTime.ofInstant(now, tz)); + } + } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/gwb5600/CasioGWB5600TimeZone.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/gwb5600/CasioGWB5600TimeZone.java index 0a9dedbb8..1801e2fb3 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/gwb5600/CasioGWB5600TimeZone.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/gwb5600/CasioGWB5600TimeZone.java @@ -17,6 +17,10 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gwb5600; +import java.util.Set; +import java.util.HashSet; +import java.util.Map; +import java.util.Arrays; import java.nio.charset.StandardCharsets; import java.time.DayOfWeek; import java.time.Instant; @@ -128,11 +132,12 @@ PONTA DELGADA E4 00 FC 04 02 this.dstSetting = dstSetting; } - static public byte[] dstWatchStateRequest(int slot) { - // request only even slots, the response will also contain the next odd slot - return new byte[] { - Casio2C2DSupport.FEATURE_DST_WATCH_STATE, - (byte) slot}; + static public Set requests(int slot) { + HashSet requests = new HashSet(); + requests.add(new Casio2C2DSupport.FeatureRequest(Casio2C2DSupport.FEATURE_DST_WATCH_STATE, (byte) (slot/2*2))); + requests.add(new Casio2C2DSupport.FeatureRequest(Casio2C2DSupport.FEATURE_DST_SETTING, (byte) slot)); + requests.add(new Casio2C2DSupport.FeatureRequest(Casio2C2DSupport.FEATURE_WORLD_CITY, (byte) slot)); + return requests; } static public byte[] dstWatchStateBytes(int slotA, CasioGWB5600TimeZone zoneA, int slotB, CasioGWB5600TimeZone zoneB) { @@ -149,12 +154,6 @@ PONTA DELGADA E4 00 FC 04 02 (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff}; } - static public byte[] dstSettingRequest(int slot) { - return new byte[] { - Casio2C2DSupport.FEATURE_DST_SETTING, - (byte) slot}; - } - public byte[] dstSettingBytes(int slot) { return new byte[] { Casio2C2DSupport.FEATURE_DST_SETTING, @@ -166,12 +165,6 @@ PONTA DELGADA E4 00 FC 04 02 dstRules}; } - static public byte[] worldCityRequest(int slot) { - return new byte[] { - Casio2C2DSupport.FEATURE_WORLD_CITY, - (byte) slot}; - } - public byte[] worldCityBytes(int slot) { byte[] bytes = { Casio2C2DSupport.FEATURE_WORLD_CITY, @@ -181,7 +174,7 @@ PONTA DELGADA E4 00 FC 04 02 return bytes; } - static CasioGWB5600TimeZone fromWatchResponses(List responses, int slot) { + static CasioGWB5600TimeZone fromWatchResponses(Map responses, int slot) { byte[] name = "unknown".getBytes(StandardCharsets.US_ASCII); byte[] number = {0,0}; byte offset = 0; @@ -189,7 +182,7 @@ PONTA DELGADA E4 00 FC 04 02 byte dstRules = 0; byte dstSetting = 0; - for (byte[] response: responses) { + for (byte[] response: responses.values()) { if (response[0] == Casio2C2DSupport.FEATURE_DST_WATCH_STATE && response.length >= 9) { if (response[1] == slot) { dstSetting = response[3]; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/gwb5600/InitOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/gwb5600/InitOperation.java deleted file mode 100644 index b6158fd32..000000000 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/casio/gwb5600/InitOperation.java +++ /dev/null @@ -1,130 +0,0 @@ -/* Copyright (C) 2023-2024 Johannes Krude - - This file is part of Gadgetbridge. - - Gadgetbridge is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Gadgetbridge is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . */ - -package nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gwb5600; - -import android.bluetooth.BluetoothGatt; -import android.bluetooth.BluetoothGattCharacteristic; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.time.Instant; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.time.format.TextStyle; -import java.util.LinkedList; -import java.util.List; -import java.util.Locale; -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.btle.actions.SetDeviceStateAction; -import nodomain.freeyourgadget.gadgetbridge.service.devices.miband.operations.OperationStatus; -import nodomain.freeyourgadget.gadgetbridge.devices.casio.CasioConstants; -import nodomain.freeyourgadget.gadgetbridge.service.devices.casio.Casio2C2DSupport; -import nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gwb5600.CasioGWB5600DeviceSupport; -import nodomain.freeyourgadget.gadgetbridge.service.devices.casio.gwb5600.CasioGWB5600TimeZone; - -public class InitOperation extends AbstractBTLEOperation { - private static final Logger LOG = LoggerFactory.getLogger(InitOperation.class); - - private final TransactionBuilder builder; - private final CasioGWB5600DeviceSupport support; - private List responses = new LinkedList(); - - public InitOperation(CasioGWB5600DeviceSupport support, TransactionBuilder builder) { - super(support); - this.support = support; - this.builder = builder; - builder.setCallback(this); - } - - @Override - public TransactionBuilder performInitialized(String taskName) throws IOException { - throw new UnsupportedOperationException("This IS the initialization class, you cannot call this method"); - } - - @Override - protected void doPerform() {//throws IOException { - builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext())); - builder.notify(getCharacteristic(CasioConstants.CASIO_ALL_FEATURES_CHARACTERISTIC_UUID), true); - for (int i = 1; i < 6; i++) { - if (i%2 == 1) - support.writeAllFeaturesRequest(builder, CasioGWB5600TimeZone.dstWatchStateRequest(i-1)); - - support.writeAllFeaturesRequest(builder, CasioGWB5600TimeZone.dstSettingRequest(i)); - support.writeAllFeaturesRequest(builder, CasioGWB5600TimeZone.worldCityRequest(i)); - } - } - - @Override - public boolean onCharacteristicChanged(BluetoothGatt gatt, - BluetoothGattCharacteristic characteristic) { - UUID characteristicUUID = characteristic.getUuid(); - byte[] data = characteristic.getValue(); - - if (characteristicUUID.equals(CasioConstants.CASIO_ALL_FEATURES_CHARACTERISTIC_UUID) && data.length > 0 && - (data[0] == Casio2C2DSupport.FEATURE_DST_WATCH_STATE || - data[0] == Casio2C2DSupport.FEATURE_DST_SETTING || - data[0] == Casio2C2DSupport.FEATURE_WORLD_CITY)) { - responses.add(data); - if (responses.size() == 13) { - TransactionBuilder builder = createTransactionBuilder("setClocks"); - setClocks(builder); - builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZED, getContext())); - builder.setCallback(null); - builder.queue(support.getQueue()); - operationFinished(); - } - return true; - } else { - LOG.info("Unhandled characteristic changed: " + characteristicUUID); - return super.onCharacteristicChanged(gatt, characteristic); - } - } - - private void setClocks(TransactionBuilder builder) { - ZoneId tz = ZoneId.systemDefault(); - Instant now = Instant.now().plusSeconds(2); - CasioGWB5600TimeZone[] timezones = { - CasioGWB5600TimeZone.fromZoneId(tz, now, tz.getDisplayName(TextStyle.SHORT, Locale.getDefault())), - CasioGWB5600TimeZone.fromWatchResponses(responses, 1), - CasioGWB5600TimeZone.fromWatchResponses(responses, 2), - CasioGWB5600TimeZone.fromWatchResponses(responses, 3), - CasioGWB5600TimeZone.fromWatchResponses(responses, 4), - CasioGWB5600TimeZone.fromWatchResponses(responses, 5), - }; - for (int i = 5; i >= 0; i--) { - if (i%2 == 0) - support.writeAllFeatures(builder, CasioGWB5600TimeZone.dstWatchStateBytes(i, timezones[i], i+1, timezones[i+1])); - support.writeAllFeatures(builder, timezones[i].dstSettingBytes(i)); - support.writeAllFeatures(builder, timezones[i].worldCityBytes(i)); - } - support.writeCurrentTime(builder, ZonedDateTime.ofInstant(now, tz)); - } - - @Override - protected void operationFinished() { - operationStatus = OperationStatus.FINISHED; - } - -}