mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge
synced 2025-01-24 16:47:32 +01: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:
parent
0f052f5467
commit
fe485d80ec
@ -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) {
|
||||
|
@ -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")))
|
||||
|
@ -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";
|
||||
}
|
||||
|
@ -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){
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user