1
0
mirror of https://codeberg.org/Freeyourgadget/Gadgetbridge synced 2024-06-16 10:00:08 +02:00

Fossil Gen6. Hybrid: added basic support for Hybrid Gen 6 (#2775)

This PR aims to add support for the newer Fossil Gen. 6 Hybrid models, which are pretty similar to the older HR's.

Here's my checklist

- [x] make GB recognize and accept new watches
- [ ] find out how SPO2 is transmitted
- [ ] extend activity data to include Oxygen data
- [x] create timeout for requests to avoid deadlocks
- [x] fix device vibration on every reconnect
- [ ] create API for voice commands
- [x] figure out how the voice data works

Co-authored-by: Daniel Dakhno <dakhnod@gmail.com>
Reviewed-on: https://codeberg.org/Freeyourgadget/Gadgetbridge/pulls/2775
Co-authored-by: dakhnod <dakhnod@noreply.codeberg.org>
Co-committed-by: dakhnod <dakhnod@noreply.codeberg.org>
This commit is contained in:
dakhnod 2022-08-24 21:56:09 +02:00 committed by vanous
parent 0f052f5467
commit fe485d80ec
8 changed files with 263 additions and 12 deletions

View File

@ -290,13 +290,18 @@ public class QHybridCoordinator extends AbstractBLEDeviceCoordinator {
private boolean isHybridHR() {
List<GBDevice> devices = GBApplication.app().getDeviceManager().getSelectedDevices();
for(GBDevice device : devices){
if(isFossilHybrid(device) && device.getName().startsWith("Hybrid HR")){
if(isHybridHR(device)){
return true;
}
}
return false;
}
private boolean isHybridHR(GBDevice device){
if(!isFossilHybrid(device)) return false;
return device.getName().startsWith("Hybrid HR") || device.getName().equals("Fossil Gen. 6 Hybrid");
}
private Version getFirmwareVersion() {
List<GBDevice> devices = GBApplication.app().getDeviceManager().getSelectedDevices();
for (GBDevice device : devices) {

View File

@ -143,6 +143,7 @@ public class QHybridSupport extends QHybridBaseSupport {
public QHybridSupport() {
super(logger);
addSupportedService(UUID.fromString("108b5094-4c03-e51c-555e-105d1a1155f0"));
addSupportedService(UUID.fromString("3dda0001-957f-7d4a-34a6-74696673696d"));
addSupportedService(GattService.UUID_SERVICE_DEVICE_INFORMATION);
addSupportedService(GattService.UUID_SERVICE_GENERIC_ACCESS);
@ -480,6 +481,10 @@ public class QHybridSupport extends QHybridBaseSupport {
for (int i = 2; i <= 7; i++)
builder.notify(getCharacteristic(UUID.fromString("3dda000" + i + "-957f-7d4a-34a6-74696673696d")), true);
builder.notify(getCharacteristic(UUID.fromString("010541ae-efe8-11c0-91c0-105d1a1155f0")), true);
builder.notify(getCharacteristic(UUID.fromString("fef9589f-9c21-4d19-9fc0-105d1a1155f0")), true);
builder.notify(getCharacteristic(UUID.fromString("842d2791-0d20-4ce4-1ada-105d1a1155f0")), true);
builder
.read(getCharacteristic(UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb")))
.read(getCharacteristic(UUID.fromString("00002a26-0000-1000-8000-00805f9b34fb")))

View File

@ -83,7 +83,9 @@ public abstract class WatchAdapter {
case "IV.0.0":
return "Hybrid HR";
case "DN.1.0":
return "Hybrid HR";
return "Hybrid HR Collider";
case "VA.0.0":
return "Fossil Gen. 6 Hybrid";
}
return "unknown Q";
}

View File

@ -26,6 +26,7 @@ public final class WatchAdapterFactory {
char hardwareVersion = firmwareVersion.charAt(2);
if(hardwareVersion == '1') return new FossilHRWatchAdapter(deviceSupport);
if(firmwareVersion.startsWith("IV0")) return new FossilHRWatchAdapter(deviceSupport);
if(firmwareVersion.startsWith("VA")) return new FossilHRWatchAdapter(deviceSupport);
char major = firmwareVersion.charAt(6);
switch (major){

View File

@ -22,6 +22,8 @@ import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.widget.Toast;
import org.json.JSONArray;
@ -114,9 +116,37 @@ public class FossilWatchAdapter extends WatchAdapter {
super(deviceSupport);
}
private final int REQUEST_TIMEOUT = 60 * 1000;
private Looper timeoutLooper = null;
private Handler timeoutHandler;
protected Thread timeoutThread = new Thread(new Runnable() {
@Override
public void run() {
Looper.prepare();
timeoutLooper = Looper.myLooper();
timeoutHandler = new Handler(timeoutLooper);
Looper.loop();
}
});
private Runnable requestTimeoutRunnable = new Runnable() {
@Override
public void run() {
String requestName = "unknown";
if(fossilRequest != null){
requestName = fossilRequest.getName();
}
log(String.format("Request %s timed out, queing next request", requestName));
fossilRequest = null;
queueNextRequest();
}
};
@Override
public void initialize() {
timeoutThread.start();
playPairingAnimation();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
queueWrite(new RequestMtuRequest(512), false);
@ -125,6 +155,26 @@ public class FossilWatchAdapter extends WatchAdapter {
getDeviceInfos();
}
private void restartRequestTimeout(){
if(timeoutLooper == null){
return;
}
stopRequestTimeout();
log("restarting request timeout");
timeoutHandler.postDelayed(
requestTimeoutRunnable,
REQUEST_TIMEOUT
);
}
private void stopRequestTimeout(){
if(timeoutLooper == null){
return;
}
timeoutHandler.removeCallbacks(requestTimeoutRunnable);
log("stopped request timeout");
}
public short getSupportedFileVersion(FileHandle handle) {
return this.supportedFileVersions.getSupportedFileVersion(handle);
}
@ -461,6 +511,8 @@ public class FossilWatchAdapter extends WatchAdapter {
return true;
case "DN.1.0":
return true;
case "VA.0.0":
return true;
}
throw new UnsupportedOperationException("model " + modelNumber + " not supported");
}
@ -477,6 +529,8 @@ public class FossilWatchAdapter extends WatchAdapter {
return false;
case "DN.1.0":
return false;
case "VA.0.0":
return false;
}
throw new UnsupportedOperationException("Model " + modelNumber + " not supported");
}
@ -613,6 +667,7 @@ public class FossilWatchAdapter extends WatchAdapter {
if (requestFinished) {
log(fossilRequest.getName() + " finished");
fossilRequest = null;
stopRequestTimeout();
} else {
return true;
}
@ -795,11 +850,13 @@ public class FossilWatchAdapter extends WatchAdapter {
return;
}
log("executing request: " + request.getName());
restartRequestTimeout();
this.fossilRequest = request;
new TransactionBuilder(request.getClass().getSimpleName()).write(getDeviceSupport().getCharacteristic(request.getRequestUUID()), request.getRequestData()).queue(getDeviceSupport().getQueue());
if (request.isFinished()) {
this.fossilRequest = null;
stopRequestTimeout();
queueNextRequest();
}
}
@ -810,6 +867,7 @@ public class FossilWatchAdapter extends WatchAdapter {
log("dropping requetst " + request.getName());
return;
}
restartRequestTimeout();
new TransactionBuilder(request.getClass().getSimpleName()).write(getDeviceSupport().getCharacteristic(request.getRequestUUID()), request.getRequestData()).queue(getDeviceSupport().getQueue());
queueNextRequest();

View File

@ -24,9 +24,13 @@ import static nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.reque
import static nodomain.freeyourgadget.gadgetbridge.util.BitmapUtil.convertDrawableToBitmap;
import static nodomain.freeyourgadget.gadgetbridge.util.StringUtils.shortenPackageName;
import android.app.Service;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCharacteristic;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
@ -37,6 +41,11 @@ import android.graphics.Paint;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.IBinder;
import android.os.Message;
import android.os.Messenger;
import android.os.RemoteException;
import android.widget.Toast;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
@ -111,6 +120,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fos
import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil.notification.DismissTextNotificationRequest;
import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil.notification.PlayCallNotificationRequest;
import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil.notification.PlayTextNotificationRequest;
import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.alexa.AlexaMessageSetRequest;
import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.application.ApplicationInformation;
import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.application.ApplicationsListRequest;
import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.async.ConfirmAppStatusRequest;
@ -153,6 +163,8 @@ import nodomain.freeyourgadget.gadgetbridge.util.UriHelper;
import nodomain.freeyourgadget.gadgetbridge.util.Version;
public class FossilHRWatchAdapter extends FossilWatchAdapter {
public static final int MESSAGE_WHAT_VOICE_DATA_RECEIVED = 0;
private byte[] phoneRandomNumber;
private byte[] watchRandomNumber;
@ -178,6 +190,22 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter {
List<ApplicationInformation> installedApplications = new ArrayList();
Messenger voiceMessenger = null;
ServiceConnection voiceServiceConnection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
GB.log("attached to voice service", GB.INFO, null);
voiceMessenger = new Messenger(service);
}
@Override
public void onServiceDisconnected(ComponentName name) {
GB.log("detached from voice service", GB.INFO, null);
voiceMessenger = null;
}
};
enum CONNECTION_MODE {
NOT_INITIALIZED,
AUTHENTICATED,
@ -188,6 +216,8 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter {
@Override
public void initialize() {
timeoutThread.start();
saveRawActivityFiles = getDeviceSpecificPreferences().getBoolean("save_raw_activity_files", false);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
@ -287,12 +317,124 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter {
});
}
private void respondToAlexa(String message, boolean isResponse){
queueWrite(new AlexaMessageSetRequest(message, isResponse, this));
}
private void attachToVoiceService(){
String servicePackage = getDeviceSpecificPreferences().getString("voice_service_package", "");
String servicePath = getDeviceSpecificPreferences().getString("voice_service_class", "");
if(servicePackage.isEmpty()){
GB.toast("voice service package is not configured", Toast.LENGTH_LONG, GB.ERROR);
respondToAlexa("voice service package not configured on phone", true);
return;
}
if(servicePath.isEmpty()){
respondToAlexa("voice service class not configured on phone", true);
GB.toast("voice service class is not configured", Toast.LENGTH_LONG, GB.ERROR);
return;
}
ComponentName component = new ComponentName(servicePackage, servicePath);
// extract to somewhere
Intent voiceIntent = new Intent("nodomain.freeyourgadget.gadgetbridge.VOICE_COMMAND");
voiceIntent.setComponent(component);
int flags = 0;
flags |= Service.BIND_AUTO_CREATE;
GB.log("binding to voice service...", GB.INFO, null);
getContext().bindService(
voiceIntent,
voiceServiceConnection,
flags
);
PackageManager pm = getContext().getPackageManager();
boolean serviceEnabled = true;
try {
int enabledState = pm.getComponentEnabledSetting(component);
if(enabledState != PackageManager.COMPONENT_ENABLED_STATE_ENABLED && enabledState != PackageManager.COMPONENT_ENABLED_STATE_DEFAULT){
respondToAlexa("voice service is disabled on phone", true);
GB.toast("voice service is disabled", Toast.LENGTH_LONG, GB.ERROR);
serviceEnabled = false;
}
}catch (IllegalArgumentException e){
serviceEnabled = false;
respondToAlexa("voice service not found on phone", true);
GB.toast("voice service not found", Toast.LENGTH_LONG, GB.ERROR);
}
if(!serviceEnabled){
detachFromVoiceService();
}
}
private void detachFromVoiceService(){
getContext().unbindService(voiceServiceConnection);
}
private void handleVoiceStatus(byte status){
if(status == 0x00){
attachToVoiceService();
}else if(status == 0x01){
detachFromVoiceService();
}
}
private void handleVoiceStatusCharacteristic(BluetoothGattCharacteristic characteristic){
byte[] value = characteristic.getValue();
handleVoiceStatus(value[0]);
}
private void handleVoiceDataCharacteristic(BluetoothGattCharacteristic characteristic){
if(voiceMessenger == null){
return;
}
Message message = Message.obtain(
null,
MESSAGE_WHAT_VOICE_DATA_RECEIVED
);
Bundle dataBundle = new Bundle(1);
dataBundle.putByteArray("VOICE_DATA", characteristic.getValue());
dataBundle.putString("VOICE_ENCODING", "OPUS");
message.setData(dataBundle);
try {
voiceMessenger.send(message);
} catch (RemoteException e) {
GB.log("error sending voice data to service", GB.ERROR, e);
GB.toast("error sending voice data to service", Toast.LENGTH_LONG, GB.ERROR);
voiceMessenger = null;
detachFromVoiceService();
respondToAlexa("error sending voice data to service", true);
}
}
@Override
public boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
switch (characteristic.getUuid().toString()){
case "010541ae-efe8-11c0-91c0-105d1a1155f0":
handleVoiceStatusCharacteristic(characteristic);
return true;
case "842d2791-0d20-4ce4-1ada-105d1a1155f0":
handleVoiceDataCharacteristic(characteristic);
return true;
}
return super.onCharacteristicChanged(gatt, characteristic);
}
private void initializeAfterWatchConfirmation(boolean authenticated) {
setNotificationConfigurations();
setQuickRepliesConfiguration();
if (authenticated) {
setVibrationStrength();
// setVibrationStrengthFromConfig();
setUnitsConfig();
syncSettings();
setTime();
@ -330,7 +472,7 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter {
queueWrite(new SelectedThemePutRequest(this, appName));
}
private void setVibrationStrength() {
private void setVibrationStrengthFromConfig() {
Prefs prefs = new Prefs(getDeviceSpecificPreferences());
int vibrationStrengh = prefs.getInt(DeviceSettingsPreferenceConst.PREF_VIBRATION_STRENGH_PERCENTAGE, 2);
if (vibrationStrengh > 0) {
@ -1302,13 +1444,7 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter {
@Override
public void onTestNewFunction() {
queueWrite((FileEncryptedInterface) new ConfigurationGetRequest(this){
@Override
protected void handleConfiguration(ConfigItem[] items) {
super.handleConfiguration(items);
LOG.debug(items[items.length - 1].toString());
}
});
setVibrationStrengthFromConfig();
}
public byte[] getSecretKey() throws IllegalAccessException {
@ -1447,7 +1583,7 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter {
overwriteButtons(null);
break;
case DeviceSettingsPreferenceConst.PREF_VIBRATION_STRENGH_PERCENTAGE:
setVibrationStrength();
setVibrationStrengthFromConfig();
break;
case "force_white_color_scheme":
loadBackground();
@ -1611,6 +1747,8 @@ public class FossilHRWatchAdapter extends FossilWatchAdapter {
boolean versionSupportsConfirmation = getCleanFWVersion().compareTo(new Version("2.22")) != -1;
versionSupportsConfirmation |= getDeviceSupport().getDevice().getModel().startsWith("VA");
if(!versionSupportsConfirmation){
GB.toast("not supported in this version", Toast.LENGTH_SHORT, GB.ERROR);
return;

View File

@ -0,0 +1,30 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.alexa;
import org.json.JSONException;
import org.json.JSONObject;
import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.adapter.fossil_hr.FossilHRWatchAdapter;
import nodomain.freeyourgadget.gadgetbridge.service.devices.qhybrid.requests.fossil_hr.json.JsonPutRequest;
public class AlexaMessageSetRequest extends JsonPutRequest {
public AlexaMessageSetRequest(String message, boolean isResponse, FossilHRWatchAdapter adapter) {
super(createResponseObject(message, isResponse), adapter);
}
public static JSONObject createResponseObject(String message, boolean isResponse){
try {
return new JSONObject()
.put("push", new JSONObject()
.put("set", new JSONObject()
.put("AlexaApp._.config.msg", new JSONObject()
.put("res", message)
.put("is_resp", isResponse)
)
)
);
} catch (JSONException e) {
e.printStackTrace();
}
return null;
}
}

View File

@ -147,6 +147,18 @@
android:title="@string/qhybrid_title_on_device_confirmation"
android:summary="@string/qhybrid_summary_on_device_confirmation" />
<EditTextPreference
android:key="voice_service_package"
android:title="Voice service package"
android:summary="Application that contains the service handling voice commands"
app:useSimpleSummaryProvider="true"/>
<EditTextPreference
android:key="voice_service_class"
android:title="Voice service full path"
android:summary="Full service path handling voice commands"
app:useSimpleSummaryProvider="true" />
</PreferenceScreen>
</androidx.preference.PreferenceScreen>