Shokz OpenSwim Pro: Initial support

This commit is contained in:
José Rebelo
2025-07-30 21:31:30 +01:00
parent 620a9a3841
commit 89b5264425
36 changed files with 1299 additions and 34 deletions
+1
View File
@@ -147,6 +147,7 @@
<w>sevostyanova</w>
<w>shahrabani</w>
<w>shimokawa</w>
<w>shokz</w>
<w>skype</w>
<w>slezak</w>
<w>sophanimus</w>
@@ -516,6 +516,13 @@ public class DeviceSettingsPreferenceConst {
public static final String PREF_SONY_ADAPTIVE_VOLUME_CONTROL = "pref_adaptive_volume_control";
public static final String PREF_SONY_WIDE_AREA_TAP = "pref_wide_area_tap";
public static final String PREF_MEDIA_SOURCE = "pref_media_source";
public static final String PREF_MEDIA_PLAYBACK_MODE = "pref_media_playback_mode";
public static final String PREF_SHOKZ_EQUALIZER_BLUETOOTH = "pref_shokz_equalizer_bluetooth";
public static final String PREF_SHOKZ_EQUALIZER_MP3 = "pref_shokz_equalizer_mp3";
public static final String PREF_SHOKZ_CONTROLS_LONG_PRESS_MULTI_FUNCTION = "shokz_controls_long_press_multi_function";
public static final String PREF_SHOKZ_CONTROLS_SIMULTANEOUS_VOLUME_UP_DOWN = "shokz_controls_simultaneous_volume_up_down";
public static final String PREF_OVERRIDE_FEATURES_ENABLED = "override_features_enabled";
public static final String PREF_OVERRIDE_FEATURES_LIST = "override_features_list";
@@ -24,6 +24,7 @@ import androidx.annotation.Nullable;
import androidx.annotation.XmlRes;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -47,6 +48,9 @@ public class DeviceSpecificSettings implements Parcelable {
private final List<Integer> rootScreens = new ArrayList<>();
private final Map<String, List<Integer>> subScreens = new LinkedHashMap<>();
/// Settings that should only be available while connected
private final List<String> connectedPreferences = new ArrayList<>();
public DeviceSpecificSettings() {
}
@@ -115,6 +119,7 @@ public class DeviceSpecificSettings implements Parcelable {
Objects.requireNonNull(subScreens.get(e.getKey())).add(screen);
}
}
connectedPreferences.addAll(deviceSpecificSettings.connectedPreferences);
}
public List<Integer> getRootScreens() {
@@ -126,6 +131,14 @@ public class DeviceSpecificSettings implements Parcelable {
return subScreens.get(key);
}
public void addConnectedPreferences(final String... preferences) {
connectedPreferences.addAll(Arrays.asList(preferences));
}
public List<String> getConnectedPreferences() {
return connectedPreferences;
}
public List<Integer> getAllNonRootScreens() {
final List<Integer> allScreens = new ArrayList<>();
for (final List<Integer> screens : subScreens.values()) {
@@ -143,7 +156,7 @@ public class DeviceSpecificSettings implements Parcelable {
return allScreens;
}
public static final Creator<DeviceSpecificSettings> CREATOR = new Creator<DeviceSpecificSettings>() {
public static final Creator<DeviceSpecificSettings> CREATOR = new Creator<>() {
@Override
public DeviceSpecificSettings createFromParcel(final Parcel in) {
final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
@@ -161,6 +174,7 @@ public class DeviceSpecificSettings implements Parcelable {
}
deviceSpecificSettings.addSubScreen(key, screens);
}
in.readStringList(deviceSpecificSettings.connectedPreferences);
return deviceSpecificSettings;
}
@@ -189,5 +203,6 @@ public class DeviceSpecificSettings implements Parcelable {
dest.writeInt(s);
}
}
dest.writeStringList(connectedPreferences);
}
}
@@ -103,6 +103,7 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i
static final String FRAGMENT_TAG = "DEVICE_SPECIFIC_SETTINGS_FRAGMENT";
private DeviceSpecificSettings deviceSpecificSettings;
private DeviceSpecificSettingsCustomizer deviceSpecificSettingsCustomizer;
private GBDevice device;
@@ -142,6 +143,7 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i
LOG.debug("{} changed, notifying customizer", changedDevice);
deviceSpecificSettingsCustomizer.onDeviceChanged(DeviceSpecificSettingsFragment.this);
}
reloadEnabledPreferences();
}
}
}
@@ -174,7 +176,7 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i
return;
}
String settingsFileSuffix = arguments.getString("settingsFileSuffix", null);
DeviceSpecificSettings deviceSpecificSettings = arguments.getParcelable("deviceSpecificSettings");
this.deviceSpecificSettings = arguments.getParcelable("deviceSpecificSettings");
this.deviceSpecificSettingsCustomizer = arguments.getParcelable("deviceSpecificSettingsCustomizer");
this.device = arguments.getParcelable("device");
@@ -241,6 +243,19 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i
}
setChangeListener(rootKey);
reloadEnabledPreferences();
}
private void reloadEnabledPreferences() {
if (deviceSpecificSettings != null) {
for (String connectedPreference : deviceSpecificSettings.getConnectedPreferences()) {
final Preference pref = findPreference(connectedPreference);
if (pref != null) {
pref.setEnabled(device.isInitialized());
}
}
}
}
private void addDynamicSettings(final String rootKey) {
@@ -811,6 +826,14 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i
addPreferenceHandlerFor(PREF_SONY_ADAPTIVE_VOLUME_CONTROL);
addPreferenceHandlerFor(PREF_SONY_WIDE_AREA_TAP);
addPreferenceHandlerFor(PREF_MEDIA_SOURCE);
addPreferenceHandlerFor(PREF_MEDIA_PLAYBACK_MODE);
addPreferenceHandlerFor(PREF_SHOKZ_EQUALIZER_BLUETOOTH);
addPreferenceHandlerFor(PREF_SHOKZ_EQUALIZER_MP3);
addPreferenceHandlerFor(PREF_SHOKZ_CONTROLS_LONG_PRESS_MULTI_FUNCTION);
addPreferenceHandlerFor(PREF_SHOKZ_CONTROLS_SIMULTANEOUS_VOLUME_UP_DOWN);
addPreferenceHandlerFor(PREF_SOUNDCORE_AMBIENT_SOUND_CONTROL);
addPreferenceHandlerFor(PREF_SOUNDCORE_WIND_NOISE_REDUCTION);
addPreferenceHandlerFor(PREF_SOUNDCORE_TRANSPARENCY_VOCAL_MODE);
@@ -16,6 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.devicesettings;
import androidx.annotation.NonNull;
import androidx.annotation.XmlRes;
import nodomain.freeyourgadget.gadgetbridge.R;
@@ -50,6 +51,7 @@ public enum DeviceSpecificSettingsScreen {
this.xml = xml;
}
@NonNull
public String getKey() {
return key;
}
@@ -50,9 +50,6 @@ public abstract class GBDeviceEvent {
/**
* Helper method to run specific actions configured in the device preferences, upon wear state
* or awake/asleep events.
*
* @param actions
* @param message
*/
protected void handleDeviceAction(final Context context, final GBDevice device, Set<String> actions, String message, String broadcastPackage) {
if (actions.isEmpty()) {
@@ -19,6 +19,8 @@ package nodomain.freeyourgadget.gadgetbridge.deviceevents;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.annotation.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -34,6 +36,18 @@ public class GBDeviceEventUpdatePreferences extends GBDeviceEvent {
public final Map<String, Object> preferences;
@NonNull
@Override
public String toString() {
final StringBuilder sb = new StringBuilder(super.toString());
sb.append("preferences: ");
for (Map.Entry<String, Object> e : preferences.entrySet()) {
sb.append(e.getKey()).append("=").append(e.getValue()).append(", ");
}
sb.setLength(sb.length() - 2);
return sb.toString();
}
public GBDeviceEventUpdatePreferences() {
this.preferences = new HashMap<>();
}
@@ -73,10 +87,11 @@ public class GBDeviceEventUpdatePreferences extends GBDeviceEvent {
private void update(final SharedPreferences prefs) {
final SharedPreferences.Editor editor = prefs.edit();
for (String key : preferences.keySet()) {
final Object value = preferences.get(key);
for (Map.Entry<String, Object> e : preferences.entrySet()) {
final String key = e.getKey();
final Object value = e.getValue();
LOG.debug("Updating {} = {}", key, value);
LOG.trace("Updating {} = {}", key, value);
if (value == null) {
editor.remove(key);
@@ -93,6 +108,7 @@ public class GBDeviceEventUpdatePreferences extends GBDeviceEvent {
} else if (value instanceof Long) {
editor.putLong(key, (Long) value);
} else if (value instanceof Set) {
//noinspection unchecked,rawtypes
editor.putStringSet(key, (Set) value);
} else {
LOG.warn("Unknown preference value type {} for {}", value.getClass(), key);
@@ -0,0 +1,70 @@
package nodomain.freeyourgadget.gadgetbridge.devices.shokz
import nodomain.freeyourgadget.gadgetbridge.R
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen
import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLClassicDeviceCoordinator
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport
import nodomain.freeyourgadget.gadgetbridge.service.devices.shokz.ShokzSupport
abstract class ShokzCoordinator : AbstractBLClassicDeviceCoordinator() {
override fun getManufacturer(): String? {
return "Shokz"
}
override fun getDeviceSupportClass(device: GBDevice): Class<out DeviceSupport?> {
return ShokzSupport::class.java
}
override fun suggestUnbindBeforePair(): Boolean {
return false
}
override fun getDefaultIconResource(): Int {
// TODO dedicated icon
return R.drawable.ic_device_headphones
}
override fun getSupportedLanguageSettings(device: GBDevice): Array<out String>? {
return arrayOf(
"en",
"zh",
"ja",
"ko",
)
}
override fun getDeviceSpecificSettings(device: GBDevice): DeviceSpecificSettings {
val settings = DeviceSpecificSettings()
settings.addRootScreen(R.xml.devicesettings_media_source)
settings.addRootScreen(R.xml.devicesettings_shokz_equalizer)
settings.addRootScreen(R.xml.devicesettings_playback_mode)
settings.addRootScreen(DeviceSpecificSettingsScreen.TOUCH_OPTIONS)
settings.addSubScreen(DeviceSpecificSettingsScreen.TOUCH_OPTIONS, R.xml.devicesettings_shokz_controls)
settings.addRootScreen(DeviceSpecificSettingsScreen.CALLS_AND_NOTIFICATIONS)
settings.addSubScreen(DeviceSpecificSettingsScreen.CALLS_AND_NOTIFICATIONS, R.xml.devicesettings_headphones)
settings.addConnectedPreferences(
DeviceSettingsPreferenceConst.PREF_LANGUAGE,
DeviceSettingsPreferenceConst.PREF_MEDIA_SOURCE,
DeviceSettingsPreferenceConst.PREF_SHOKZ_EQUALIZER_BLUETOOTH,
DeviceSettingsPreferenceConst.PREF_SHOKZ_EQUALIZER_MP3,
DeviceSettingsPreferenceConst.PREF_MEDIA_PLAYBACK_MODE,
DeviceSpecificSettingsScreen.TOUCH_OPTIONS.key,
DeviceSettingsPreferenceConst.PREF_SHOKZ_CONTROLS_LONG_PRESS_MULTI_FUNCTION,
DeviceSettingsPreferenceConst.PREF_SHOKZ_CONTROLS_SIMULTANEOUS_VOLUME_UP_DOWN,
)
return settings
}
override fun getDeviceSpecificSettingsCustomizer(device: GBDevice): DeviceSpecificSettingsCustomizer? {
return ShokzSettingsCustomizer()
}
}
@@ -0,0 +1,14 @@
package nodomain.freeyourgadget.gadgetbridge.devices.shokz
import nodomain.freeyourgadget.gadgetbridge.R
import java.util.regex.Pattern
class ShokzOpenSwimProCoordinator: ShokzCoordinator() {
override fun getSupportedDeviceName(): Pattern? {
return Pattern.compile("^OpenSwim Pro by Shokz$")
}
override fun getDeviceNameResource(): Int {
return R.string.devicetype_shokz_openswim_pro
}
}
@@ -0,0 +1,75 @@
/* Copyright (C) 2025 José Rebelo
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.devices.shokz
import androidx.preference.Preference
import kotlinx.parcelize.Parcelize
import nodomain.freeyourgadget.gadgetbridge.GBApplication
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler
import nodomain.freeyourgadget.gadgetbridge.service.devices.shokz.ShokzMediaSource
import nodomain.freeyourgadget.gadgetbridge.util.Prefs
import org.slf4j.LoggerFactory
@Parcelize
class ShokzSettingsCustomizer : DeviceSpecificSettingsCustomizer {
override fun onPreferenceChange(
preference: Preference,
handler: DeviceSpecificSettingsHandler
) {
}
override fun onDeviceChanged(handler: DeviceSpecificSettingsHandler) {
hideEqualizers(handler, GBApplication.getDevicePrefs(handler.device))
}
override fun customizeSettings(
handler: DeviceSpecificSettingsHandler,
genericDevicePrefs: Prefs,
rootKey: String?
) {
hideEqualizers(handler, genericDevicePrefs)
}
private fun hideEqualizers(
handler: DeviceSpecificSettingsHandler,
devicePrefs: Prefs
) {
val mediaSourceValue: String =
devicePrefs.getString(DeviceSettingsPreferenceConst.PREF_MEDIA_SOURCE, "")
val mediaSource = ShokzMediaSource.fromPreference(mediaSourceValue) ?: run {
LOG.warn("Unknown media source {}", mediaSourceValue)
null
}
handler.findPreference<Preference>(DeviceSettingsPreferenceConst.PREF_SHOKZ_EQUALIZER_BLUETOOTH)?.isVisible =
mediaSource == ShokzMediaSource.BLUETOOTH
handler.findPreference<Preference>(DeviceSettingsPreferenceConst.PREF_SHOKZ_EQUALIZER_MP3)?.isVisible =
mediaSource == ShokzMediaSource.MP3
handler.findPreference<Preference>(DeviceSettingsPreferenceConst.PREF_MEDIA_PLAYBACK_MODE)?.isVisible =
mediaSource == ShokzMediaSource.MP3
}
override fun getPreferenceKeysWithSummary(): Set<String> {
return setOf()
}
companion object {
private val LOG = LoggerFactory.getLogger(ShokzSettingsCustomizer::class.java)
}
}
@@ -315,6 +315,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.redmibuds.RedmiBuds6ProCoord
import nodomain.freeyourgadget.gadgetbridge.devices.roidmi.Roidmi1Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.roidmi.Roidmi3Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.scannable.ScannableDeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.shokz.ShokzOpenSwimProCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.smaq2oss.SMAQ2OSSCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.soflow.SoFlowCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyLinkBudsCoordinator;
@@ -662,6 +663,7 @@ public enum DeviceType {
SONY_WF_C700N(SonyWFC700NCoordinator.class),
SONY_WF_C710N(SonyWFC710NCoordinator.class),
PIXEL_BUDS_A(PixelBudsACoordinator.class),
SHOKZ_OPENSWIM_PRO(ShokzOpenSwimProCoordinator.class),
SOUNDCORE_LIBERTY3_PRO(SoundcoreLiberty3ProCoordinator.class),
SOUNDCORE_LIBERTY4_NC(SoundcoreLiberty4NCCoordinator.class),
SOUNDCORE_MOTION300(SoundcoreMotion300Coordinator.class),
@@ -0,0 +1,41 @@
package nodomain.freeyourgadget.gadgetbridge.service
import android.bluetooth.BluetoothAdapter
import android.content.Context
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
import nodomain.freeyourgadget.gadgetbridge.model.CallSpec
import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec
import nodomain.freeyourgadget.gadgetbridge.service.btbr.AbstractBTBRDeviceSupport
import org.slf4j.Logger
abstract class AbstractHeadphoneBTBRDeviceSupport(logger: Logger, bufferSize: Int = 1024) :
AbstractBTBRDeviceSupport(logger, bufferSize), HeadphoneHelper.Callback {
private lateinit var headphoneHelper: HeadphoneHelper
override fun setContext(gbDevice: GBDevice, btAdapter: BluetoothAdapter, context: Context) {
super.setContext(gbDevice, btAdapter, context)
headphoneHelper = HeadphoneHelper(getContext(), gbDevice, this)
}
override fun dispose() {
synchronized(ConnectionMonitor) {
headphoneHelper.dispose()
super.dispose()
}
}
override fun onSetCallState(callSpec: CallSpec) {
headphoneHelper.onSetCallState(callSpec)
}
override fun onNotification(notificationSpec: NotificationSpec) {
headphoneHelper.onNotification(notificationSpec)
}
override fun onSendConfiguration(config: String) {
if (!headphoneHelper.onSendConfiguration(config)) {
super.onSendConfiguration(config)
}
}
}
@@ -16,6 +16,10 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.btbr;
import android.annotation.SuppressLint;
import android.bluetooth.BluetoothDevice;
import android.os.ParcelUuid;
import org.slf4j.Logger;
import java.io.IOException;
@@ -24,6 +28,7 @@ import java.util.UUID;
import nodomain.freeyourgadget.gadgetbridge.Logging;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.service.AbstractDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BleNamesResolver;
/**
* Abstract base class for devices connected through a serial protocol, like RFCOMM BT or TCP socket.
@@ -42,11 +47,15 @@ public abstract class AbstractBTBRDeviceSupport extends AbstractDeviceSupport im
private BtBRQueue mQueue;
private UUID mSupportedService = null;
private int mBufferSize = 1024;
private final int mBufferSize;
private final Logger logger;
public AbstractBTBRDeviceSupport(Logger logger) {
/**
* @param bufferSize should be larger than the maximum expected message side, or messages might be lost.
*/
public AbstractBTBRDeviceSupport(Logger logger, final int bufferSize) {
this.logger = logger;
this.mBufferSize = bufferSize;
if (logger == null) {
throw new IllegalArgumentException("logger must not be null");
}
@@ -57,6 +66,21 @@ public abstract class AbstractBTBRDeviceSupport extends AbstractDeviceSupport im
synchronized (ConnectionMonitor) {
final UUID supportedService = getSupportedService();
if (supportedService == null) {
// Before throwing the exception, list the available UUIDs
final BluetoothDevice btDevice = getBluetoothAdapter().getRemoteDevice(gbDevice.getAddress());
@SuppressLint("MissingPermission") final ParcelUuid[] uuids = btDevice.getUuids();
if (uuids == null || uuids.length == 0) {
logger.warn("Device provided no UUIDs to connect to: {}", gbDevice);
} else {
for (ParcelUuid uuid : uuids) {
logger.debug(
"discovered service: {}: {}",
BleNamesResolver.resolveServiceName(uuid.toString()),
uuid
);
}
}
throw new NullPointerException("No supported service UUID specified");
}
@@ -136,10 +160,6 @@ public abstract class AbstractBTBRDeviceSupport extends AbstractDeviceSupport im
return mSupportedService;
}
protected void setBufferSize(int bufferSize) {
mBufferSize = bufferSize;
}
protected int getBufferSize() {
return mBufferSize;
}
@@ -26,8 +26,6 @@ public interface SocketCallback {
/**
* Read data from InputStream of BluetoothSocket
*
* @param data
*/
void onSocketRead(byte[] data);
@@ -30,7 +30,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.btbr.BtBRAction;
/**
* Invokes a write operation on a given socket.
* The result status will be made available asynchronously through the
* {@link SocketCallback}
* {@link nodomain.freeyourgadget.gadgetbridge.service.btbr.SocketCallback}
*/
public class WriteAction extends BtBRAction {
private static final Logger LOG = LoggerFactory.getLogger(WriteAction.class);
@@ -59,7 +59,7 @@ public class WriteAction extends BtBRAction {
protected boolean writeValue(byte[] value) {
if (LOG.isDebugEnabled()) {
LOG.debug("writing to socket: " + Logging.formatBytes(value));
LOG.debug("writing to socket: {}", Logging.formatBytes(value));
}
try {
mOutputStream.write(value);
@@ -485,6 +485,11 @@ public class BleNamesResolver {
mServices.put("0000185a-0000-1000-8000-00805f9b34fb", "Industrial Measurement Device");
mServices.put("0000185b-0000-1000-8000-00805f9b34fb", "Ranging");
mServices.put("00001101-0000-1000-8000-00805f9b34fb", "SPP (Serial Port Profile)");
mServices.put("0000111e-0000-1000-8000-00805f9b34fb", "HFP HS (Hands-Free Profile)");
mServices.put("0000110b-0000-1000-8000-00805f9b34fb", "A2DP Sink");
mServices.put("0000110e-0000-1000-8000-00805f9b34fb", "AVRCP Remote");
mServices.put("0000fdab-0000-1000-8000-00805f9b34fb", "(Propr: Xiaomi Proximity Unlock Service)");
mServices.put("0000fe95-0000-1000-8000-00805f9b34fb", "(Propr: Xiaomi Wear Service)");
mServices.put("0000fee0-0000-3512-2118-0009af100700", "(Propr: Xiaomi MiLi Service)");
@@ -70,9 +70,8 @@ public class AAWirelessSupport extends AbstractBTBRDeviceSupport {
private final BroadcastReceiver commandReceiver = new AAWirelessCommandReceiver();
public AAWirelessSupport() {
super(LOG);
super(LOG, MAX_MTU);
addSupportedService(UUID_SERVICE_AAWIRELESS);
setBufferSize(MAX_MTU);
}
@Override
@@ -101,9 +101,8 @@ public class ZeppOsBtbrSupport extends AbstractBTBRDeviceSupport implements Zepp
private long lastWrite = -1;
public ZeppOsBtbrSupport() {
super(LOG);
super(LOG, MAX_MTU);
addSupportedService(HuamiService.UUID_BT_SERIAL_SERVICE);
setBufferSize(MAX_MTU);
}
@Override
@@ -17,17 +17,11 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.huawei;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import nodomain.freeyourgadget.gadgetbridge.devices.huawei.HuaweiConstants;
public class HonorBRSupport extends HuaweiBRSupport {
private static final Logger LOG = LoggerFactory.getLogger(HonorBRSupport.class);
public HonorBRSupport() {
super();
addSupportedService(HuaweiConstants.UUID_SERVICE_HONOR_SDP);
setBufferSize(1032);
}
}
@@ -49,9 +49,8 @@ public class HuaweiBRSupport extends AbstractBTBRDeviceSupport {
private final HuaweiSupportProvider supportProvider;
public HuaweiBRSupport() {
super(LOG);
super(LOG, 1032);
addSupportedService(HuaweiConstants.UUID_SERVICE_HUAWEI_SDP);
setBufferSize(1032);
supportProvider = new HuaweiSupportProvider(this);
}
@@ -56,7 +56,6 @@ public class HuaweiFreebudsSupport extends HuaweiBRSupport implements HeadphoneH
public HuaweiFreebudsSupport() {
super();
addSupportedService(UUID.fromString("00001101-0000-1000-8000-00805f9b34fb"));
setBufferSize(1032);
}
@Override
@@ -0,0 +1,74 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.shokz
/// Naming: LONG_PRESS_MULTI_FUNCTION __ SIMULTANEOUS_VOLUME_UP_DOWN
enum class ShokzControls(val code: Int) {
ASSISTANT__MEDIA_SOURCE(0x01),
MEDIA_SOURCE__ASSISTANT(0x02),
MEDIA_SOURCE__MEDIA_SOURCE(0x03),
ASSISTANT__ASSISTANT(0x04),
;
companion object {
fun fromPreference(value: String): ShokzControls? = entries.find { it.name == value.uppercase() }
fun fromCode(code: Int): ShokzControls? = entries.find { it.code == code }
}
}
enum class ShokzEqualizer(val code: Int) {
STANDARD(0x01),
VOCAL(0x02),
SWIMMING(0x07),
;
companion object {
fun fromPreference(value: String): ShokzEqualizer? = entries.find { it.name == value.uppercase() }
fun fromCode(code: Int): ShokzEqualizer? = entries.find { it.code == code }
}
}
enum class ShokzMp3PlaybackMode(val code: Int) {
NORMAL(0x00),
SHUFFLE(0x01),
REPEAT(0x02),
;
companion object {
fun fromPreference(value: String): ShokzMp3PlaybackMode? = entries.find { it.name == value.uppercase() }
fun fromCode(code: Int): ShokzMp3PlaybackMode? = entries.find { it.code == code }
}
}
enum class ShokzMediaSource(val code: Int) {
BLUETOOTH(0x00),
MP3(0x01),
;
companion object {
fun fromPreference(value: String): ShokzMediaSource? = entries.find { it.name == value.uppercase() }
fun fromCode(code: Int): ShokzMediaSource? = entries.find { it.code == code }
}
}
enum class ShokzPlaybackStatus(val code: Int) {
PAUSED(0x00),
PLAYING(0xff),
;
companion object {
fun fromPreference(value: String): ShokzPlaybackStatus? = entries.find { it.name == value.uppercase() }
fun fromCode(code: Int): ShokzPlaybackStatus? = entries.find { it.code == code }
}
}
enum class ShokzLanguage(val locale: String, val code: Int) {
ENGLISH("en", 0x00),
CHINESE("zh", 0x01),
JAPANESE("ja", 0x02),
KOREAN("ko", 0x03),
;
companion object {
fun fromLocale(locale: String): ShokzLanguage? = entries.find { it.locale == locale }
fun fromCode(code: Int): ShokzLanguage? = entries.find { it.code == code }
}
}
@@ -0,0 +1,75 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.shokz
enum class ShokzCommand(
val group: Int,
val code: Int
) {
BATTERY_GET(0x02, 0x0003),
BATTERY_RET(0x02, 0x8003),
CONTROLS_ACK(0x01, 0x8024),
CONTROLS_GET(0x02, 0x001b),
CONTROLS_RET(0x02, 0x801b),
CONTROLS_SET(0x01, 0x0024),
BUTTON_NEXT_ACK(0x01, 0x8008),
BUTTON_NEXT(0x01, 0x0008),
BUTTON_PAUSE_ACK(0x01, 0x8004),
BUTTON_PAUSE(0x01, 0x0004),
BUTTON_PLAY_ACK(0x01, 0x8003),
BUTTON_PLAY(0x01, 0x0003),
BUTTON_PREV_ACK(0x01, 0x8007),
BUTTON_PREV(0x01, 0x0007),
EQ_UNK_ACK_1(0x01, 0x800e),
EQUALIZER_ACK(0x03, 0x0009),
EQUALIZER_GET(0x02, 0x0008),
EQUALIZER_RET(0x02, 0x8008),
EQUALIZER_SET(0x01, 0x000e),
FIRMWARE_GET(0x02, 0x0002),
FIRMWARE_RET(0x02, 0x8002),
LANGUAGE_ACK(0x01, 0x8002),
LANGUAGE_GET(0x02, 0x0006),
LANGUAGE_RET(0x02, 0x8006),
LANGUAGE_SET(0x01, 0x0002),
MEDIA_SOURCE_ACK(0x01, 0x8025),
MEDIA_SOURCE_NOTIFY(0x03, 0x000c),
MEDIA_SOURCE_SET(0x01, 0x0025),
MODE_BLUETOOTH_UNK_0(0x03, 0x000d),
MEDIA_SOURCE_GET(0x02, 0x001c),
MEDIA_SOURCE_RET(0x02, 0x801c),
MODE_MP3_ACK(0x02, 0x801e),
MODE_MP3_SET(0x02, 0x001e),
MP3_PLAYBACK_MODE_ACK(0x01, 0x8026),
MP3_PLAYBACK_MODE_GET(0x02, 0x001d),
MP3_PLAYBACK_MODE_RET(0x02, 0x801d),
MP3_PLAYBACK_MODE_SET(0x01, 0x0026),
MULTIPOINT_CONNECT_ACK(0x01, 0x800b),
MULTIPOINT_CONNECT_REQ(0x01, 0x000b),
MULTIPOINT_DEVICE_CONNECTION_NOTIFY(0x03, 0x000b),
MULTIPOINT_DEVICES_GET(0x02, 0x0004),
MULTIPOINT_DEVICES_RET(0x02, 0x8004),
MULTIPOINT_DISCONNECT_ACK(0x01, 0x800c),
MULTIPOINT_DISCONNECT_REQ(0x01, 0x000c),
MULTIPOINT_OFF_ACK(0x01, 0x8010),
MULTIPOINT_OFF(0x01, 0x0010),
MULTIPOINT_ON_ACK(0x01, 0x8011),
MULTIPOINT_ON(0x01, 0x0011),
MULTIPOINT_PAIR_SECOND_FINISH(0x01, 0x0023),
MULTIPOINT_START_PAIR_ACK(0x01, 0x8022),
MULTIPOINT_START_PAIR_REQ(0x01, 0x001d),
PLAYBACK_STATUS_GET(0x02, 0x0005),
PLAYBACK_STATUS_NOTIFY(0x03, 0x0006),
PLAYBACK_STATUS_RET(0x02, 0x8005),
MULTIPOINT_GET(0x02, 0x0010),
MULTIPOINT_RET(0x02, 0x8010),
VOLUME_ACK(0x01, 0x800a),
VOLUME_GET(0x02, 0x0007),
VOLUME_NOTIFY(0x03, 0x0005),
VOLUME_RET(0x02, 0x8007),
VOLUME_SET(0x01, 0x000a),
;
companion object {
fun getCommand(group: Int, code: Int): ShokzCommand? = entries.find {
it.group == group && it.code == code
}
}
}
@@ -0,0 +1,24 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.shokz
data class ShokzMessage(
val command: ShokzCommand,
val args: ByteArray = byteArrayOf()
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ShokzMessage
if (command != other.command) return false
if (!args.contentEquals(other.args)) return false
return true
}
override fun hashCode(): Int {
var result = command.hashCode()
result = 31 * result + args.contentHashCode()
return result
}
}
@@ -0,0 +1,626 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.shokz
import android.os.Handler
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
import nodomain.freeyourgadget.gadgetbridge.service.AbstractHeadphoneBTBRDeviceSupport
import nodomain.freeyourgadget.gadgetbridge.service.btbr.TransactionBuilder
import nodomain.freeyourgadget.gadgetbridge.util.CheckSums
import nodomain.freeyourgadget.gadgetbridge.util.kotlin.stringUntilNullTerminator
import org.slf4j.LoggerFactory
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.LinkedList
import java.util.Queue
import java.util.UUID
import kotlin.math.floor
class ShokzSupport : AbstractHeadphoneBTBRDeviceSupport(LOG, MAX_MTU) {
private val packetBuffer: ByteBuffer = ByteBuffer.allocate(MAX_MTU).order(ByteOrder.LITTLE_ENDIAN)
private val messageQueue: Queue<ShokzMessage> = LinkedList()
private var pendingMessage: ShokzMessage? = null
private var timeoutRetries = 0
private val timeoutHandler = Handler()
init {
addSupportedService(UUID_SERVICE_SHOKZ)
}
override fun useAutoConnect(): Boolean {
return true
}
override fun initializeDevice(builder: TransactionBuilder): TransactionBuilder {
packetBuffer.clear()
timeoutHandler.removeCallbacksAndMessages(null)
messageQueue.clear()
pendingMessage = null
timeoutRetries = 0
builder.setDeviceState(GBDevice.State.INITIALIZING)
// Send the fw version request directly, but queue everything else, otherwise the device does not respond to all
builder.write(*encodeCommand(ShokzCommand.FIRMWARE_GET))
pendingMessage = ShokzMessage(ShokzCommand.FIRMWARE_GET)
timeoutHandler.postDelayed({ onCommandTimeout() }, 2000L)
queueCommand(ShokzCommand.MEDIA_SOURCE_GET)
queueCommand(ShokzCommand.BATTERY_GET)
queueCommand(ShokzCommand.EQUALIZER_GET)
queueCommand(ShokzCommand.PLAYBACK_STATUS_GET)
queueCommand(ShokzCommand.VOLUME_GET)
queueCommand(ShokzCommand.MP3_PLAYBACK_MODE_GET)
queueCommand(ShokzCommand.CONTROLS_GET)
queueCommand(ShokzCommand.LANGUAGE_GET)
return builder
}
override fun dispose() {
synchronized(ConnectionMonitor) {
timeoutHandler.removeCallbacksAndMessages(null)
super.dispose()
}
}
override fun onSocketRead(data: ByteArray) {
packetBuffer.put(data)
packetBuffer.flip()
while (packetBuffer.hasRemaining()) {
packetBuffer.mark()
if (packetBuffer.remaining() < 10) {
// not enough bytes for min packet
packetBuffer.reset()
break
}
val preamble = packetBuffer.getShort()
if (preamble != PACKET_PREAMBLE) {
LOG.warn("Unexpected byte 0x{} is not preamble, skipping 2b", preamble.toHexString())
continue
}
val packetLength = packetBuffer.getShort()
if (packetBuffer.remaining() < 4 + packetLength) {
// not enough bytes for packetIdx + packet
packetBuffer.reset()
break
}
packetBuffer.get().toInt() // always 1?
packetBuffer.get().toInt() // isAck?
packetBuffer.getShort() // always 0?
val payloadLength = packetBuffer.getShort().toInt() and 0xffff
if (packetBuffer.remaining() < 2 + payloadLength) {
// not enough bytes for payload + crc
packetBuffer.reset()
break
}
val crc = packetBuffer.getShort().toInt() and 0xffff
val payload = ByteArray(payloadLength)
packetBuffer.get(payload)
val expectedCrc = CheckSums.crc16_maxim(payload, 0, payload.size)
if (crc != expectedCrc) {
LOG.warn("Invalid CRC: got 0x{}, expected 0x{}", crc.toHexString(), expectedCrc.toHexString())
continue
}
var sendNext: Boolean
try {
sendNext = handlePayload(payload)
} catch (e: Exception) {
LOG.error("Failed to handle payload", e)
sendNext = true
}
if (sendNext) {
sendNextCommand()
}
}
packetBuffer.compact()
}
override fun onSendConfiguration(config: String) {
when (config) {
DeviceSettingsPreferenceConst.PREF_LANGUAGE -> setLanguage()
DeviceSettingsPreferenceConst.PREF_SHOKZ_EQUALIZER_BLUETOOTH,
DeviceSettingsPreferenceConst.PREF_SHOKZ_EQUALIZER_MP3 -> setEqualizer(config)
DeviceSettingsPreferenceConst.PREF_MEDIA_SOURCE -> setMediaSource()
DeviceSettingsPreferenceConst.PREF_MEDIA_PLAYBACK_MODE -> setMediaPlaybackMode()
DeviceSettingsPreferenceConst.PREF_SHOKZ_CONTROLS_LONG_PRESS_MULTI_FUNCTION,
DeviceSettingsPreferenceConst.PREF_SHOKZ_CONTROLS_SIMULTANEOUS_VOLUME_UP_DOWN -> setControls()
else -> super.onSendConfiguration(config)
}
}
private fun setLanguage() {
val localeString: String = devicePrefs.getString("language", "en")
val language = ShokzLanguage.fromLocale(localeString) ?: run {
LOG.warn("Unknown language {}, falling back to english", localeString)
ShokzLanguage.ENGLISH
}
LOG.info("Setting language to {}", language)
queueCommand(
ShokzCommand.LANGUAGE_SET,
byteArrayOf(language.code.toByte(), 0x00, 0x00, 0x00)
)
}
private fun setEqualizer(config: String) {
val value: String = devicePrefs.getString(config, "standard")
val equalizer = ShokzEqualizer.fromPreference(value) ?: run {
LOG.warn("Unknown equalizer {}, falling back to standard", value)
ShokzEqualizer.STANDARD
}
LOG.info("Setting equalizer to {}", equalizer)
val args = when (equalizer) {
ShokzEqualizer.STANDARD,
ShokzEqualizer.SWIMMING -> {
byteArrayOf(
equalizer.code.toByte(), 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00
)
}
ShokzEqualizer.VOCAL -> {
byteArrayOf(
equalizer.code.toByte(), 0xfc.toByte(), 0x00, 0x03,
0x02, 0x02, 0x00, 0x00
)
}
}
queueCommand(ShokzCommand.EQUALIZER_SET, args)
}
private fun setMediaSource() {
val value: String = devicePrefs.getString(DeviceSettingsPreferenceConst.PREF_MEDIA_SOURCE, "bluetooth")
val mediaSource = ShokzMediaSource.fromPreference(value) ?: run {
LOG.warn("Unknown media source {}, falling back to bluetooth", value)
ShokzMediaSource.BLUETOOTH
}
LOG.info("Setting media source to {}", mediaSource)
queueCommand(
ShokzCommand.MEDIA_SOURCE_SET,
byteArrayOf(mediaSource.code.toByte(), 0x00, 0x00, 0x00)
)
}
private fun setMediaPlaybackMode() {
val value: String = devicePrefs.getString(DeviceSettingsPreferenceConst.PREF_MEDIA_PLAYBACK_MODE, "normal")
val playbackMode = ShokzMp3PlaybackMode.fromPreference(value) ?: run {
LOG.warn("Unknown playback mode {}, falling back to normal", value)
ShokzMp3PlaybackMode.NORMAL
}
LOG.info("Setting playback mode to {}", playbackMode)
queueCommand(
ShokzCommand.MP3_PLAYBACK_MODE_SET,
byteArrayOf(playbackMode.code.toByte(), 0x00, 0x00, 0x00)
)
}
private fun setControls() {
val valueLongPress: String = devicePrefs.getString(
DeviceSettingsPreferenceConst.PREF_SHOKZ_CONTROLS_LONG_PRESS_MULTI_FUNCTION,
"standard"
)
val valueVolumeUpDown: String = devicePrefs.getString(
DeviceSettingsPreferenceConst.PREF_SHOKZ_CONTROLS_SIMULTANEOUS_VOLUME_UP_DOWN,
"standard"
)
val controls = when {
valueLongPress == "assistant" && valueVolumeUpDown == "media_source" -> ShokzControls.ASSISTANT__MEDIA_SOURCE
valueLongPress == "media_source" && valueVolumeUpDown == "assistant" -> ShokzControls.MEDIA_SOURCE__ASSISTANT
valueLongPress == "media_source" && valueVolumeUpDown == "media_source" -> ShokzControls.MEDIA_SOURCE__MEDIA_SOURCE
valueLongPress == "assistant" && valueVolumeUpDown == "assistant" -> ShokzControls.ASSISTANT__ASSISTANT
else -> {
LOG.warn(
"Unknown controls {}/{}, falling back to {}",
valueLongPress, valueVolumeUpDown,
ShokzControls.ASSISTANT__MEDIA_SOURCE
)
ShokzControls.ASSISTANT__MEDIA_SOURCE
}
}
LOG.info("Setting controls to {}", controls)
queueCommand(
ShokzCommand.CONTROLS_SET,
byteArrayOf(controls.code.toByte(), 0x00, 0x00, 0x00)
)
}
private fun handlePayload(payload: ByteArray): Boolean {
if (LOG.isTraceEnabled) {
LOG.trace("Processing payload: {}", payload.toHexString())
}
val buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN)
val unk1 = buf.getInt() // 0x01
if (unk1 != 0x01) {
LOG.warn("Unexpected unk1 0x{}", unk1.toHexString())
return true
}
val len = buf.getInt()
if (len != buf.remaining()) {
LOG.warn("Unexpected number of bytes in payload: got 0x{}, expected 0x{}", buf.remaining(), len)
return true
}
val unk2 = buf.getInt() // 0x02
if (unk2 != 0x02) {
LOG.warn("Unexpected unk2 0x{}", unk2.toHexString())
return true
}
val unk3 = buf.getInt() // 0x04
if (unk3 != 0x04) {
LOG.warn("Unexpected unk3 0x{}", unk3.toHexString())
return true
}
val group = buf.getInt()
val unk4 = buf.getInt() // 0x03
if (unk4 != 0x03) {
LOG.warn("Unexpected unk4 0x{}", unk4.toHexString())
return true
}
val unk5 = buf.getInt() // 0x04
if (unk5 != 0x04) {
LOG.warn("Unexpected unk5 0x{}", unk5.toHexString())
return true
}
val code = buf.getInt()
val unk6 = buf.getInt() // 0x04
if (unk6 != 0x04) {
LOG.warn("Unexpected unk6 0x{}", unk6.toHexString())
return true
}
val argsLength = buf.getInt()
val args = ByteArray(argsLength)
buf.get(args)
val command = ShokzCommand.getCommand(group, code)
if (command == null) {
LOG.warn("Unknown command: group 0x{}, code 0x{}", group.toHexString(), code.toHexString())
return true
}
handleCommand(command, args)
return pendingMessage?.let { msg ->
(msg.command.code or MASK_RESPONSE) == command.code
} ?: false
}
private fun handleCommand(command: ShokzCommand, args: ByteArray) {
val buf = ByteBuffer.wrap(args).order(ByteOrder.LITTLE_ENDIAN)
LOG.debug("Handling command {} args={}", command, args.toHexString())
when (command) {
ShokzCommand.FIRMWARE_RET -> {
val zero = buf.get().toInt()
if (zero != 0) {
LOG.warn("Unexpected non-zero byte 0x{} for {}", zero.toHexString(), command)
return
}
val firmware = buf.stringUntilNullTerminator()
if (firmware == null) {
LOG.warn("Failed to get firmware from payload")
return
}
LOG.info("Got firmware: {}", firmware)
val versionInfoEvent = GBDeviceEventVersionInfo()
versionInfoEvent.fwVersion = firmware
evaluateGBDeviceEvent(versionInfoEvent)
gbDevice.setUpdateState(GBDevice.State.INITIALIZED, context)
}
ShokzCommand.MEDIA_SOURCE_RET -> {
val zero = buf.get().toInt()
if (zero != 0) {
LOG.warn("Unexpected non-zero byte 0x{} for {}", zero.toHexString(), command)
return
}
val code = buf.get().toInt() and 0xff
val mode = ShokzMediaSource.fromCode(code)
if (mode == null) {
LOG.warn("Unknown mode code 0x{}", code.toHexString())
return
}
LOG.info("Got mode: {}", mode)
evaluateGBDeviceEvent(
GBDeviceEventUpdatePreferences(
DeviceSettingsPreferenceConst.PREF_MEDIA_SOURCE,
mode.name.lowercase()
)
)
}
ShokzCommand.BATTERY_RET -> {
val zero = buf.get().toInt()
if (zero != 0) {
LOG.warn("Unexpected non-zero byte 0x{} for {}", zero.toHexString(), command)
return
}
val batteryPercentage = ((buf.get().toInt() and 0xff) + 1) * 10
LOG.info("Got battery: {}%", batteryPercentage)
val batteryInfoEvent = GBDeviceEventBatteryInfo()
batteryInfoEvent.level = batteryPercentage
evaluateGBDeviceEvent(batteryInfoEvent)
}
ShokzCommand.EQUALIZER_RET, ShokzCommand.EQUALIZER_ACK -> {
if (command == ShokzCommand.EQUALIZER_RET) {
val zero = buf.get().toInt()
if (zero != 0) {
LOG.warn("Unexpected non-zero byte 0x{} for {}", zero.toHexString(), command)
return
}
}
val code = buf.get().toInt() and 0xff
val equalizer = ShokzEqualizer.fromCode(code)
if (equalizer == null) {
LOG.warn("Unknown equalizer code 0x{}", code.toHexString())
return
}
LOG.info("Got equalizer: {}", equalizer)
val mediaSourceValue: String =
devicePrefs.getString(DeviceSettingsPreferenceConst.PREF_MEDIA_SOURCE, "bluetooth")
val mediaSource = ShokzMediaSource.fromPreference(mediaSourceValue) ?: run {
LOG.warn("Unknown media source {}, falling back to bluetooth", mediaSourceValue)
ShokzMediaSource.BLUETOOTH
}
devicePrefs.getString(DeviceSettingsPreferenceConst.PREF_LANGUAGE, "en")
evaluateGBDeviceEvent(
GBDeviceEventUpdatePreferences(
when (mediaSource) {
ShokzMediaSource.BLUETOOTH -> DeviceSettingsPreferenceConst.PREF_SHOKZ_EQUALIZER_BLUETOOTH
ShokzMediaSource.MP3 -> DeviceSettingsPreferenceConst.PREF_SHOKZ_EQUALIZER_MP3
},
equalizer.name.lowercase()
)
)
}
ShokzCommand.PLAYBACK_STATUS_RET -> {
val zero = buf.get().toInt()
if (zero != 0) {
LOG.warn("Unexpected non-zero byte 0x{} for {}", zero.toHexString(), command)
return
}
val code = buf.get().toInt() and 0xff
val playbackStatus = ShokzPlaybackStatus.fromCode(code)
if (playbackStatus == null) {
LOG.warn("Unknown playback status code 0x{}", code.toHexString())
return
}
LOG.info("Got playback status: {}", playbackStatus)
// TODO handle
}
ShokzCommand.VOLUME_RET -> {
val zero = buf.get().toInt()
if (zero != 0) {
LOG.warn("Unexpected non-zero byte 0x{} for {}", zero.toHexString(), command)
return
}
val percentage = floor(((buf.get().toInt() and 0xff) / 16) * 100 + 0.5)
LOG.info("Got volume: {}", percentage)
// TODO handle
}
ShokzCommand.MP3_PLAYBACK_MODE_RET -> {
val zero = buf.get().toInt()
if (zero != 0) {
LOG.warn("Unexpected non-zero byte 0x{} for {}", zero.toHexString(), command)
return
}
val code = buf.get().toInt() and 0xff
val playbackMode = ShokzMp3PlaybackMode.fromCode(code)
if (playbackMode == null) {
LOG.warn("Unknown mp3 playback mode code 0x{}", code.toHexString())
return
}
LOG.info("Got mp3 playback mode: {}", playbackMode)
evaluateGBDeviceEvent(
GBDeviceEventUpdatePreferences(
DeviceSettingsPreferenceConst.PREF_MEDIA_PLAYBACK_MODE,
playbackMode.name.lowercase()
)
)
}
ShokzCommand.CONTROLS_RET -> {
val zero = buf.get().toInt()
if (zero != 0) {
LOG.warn("Unexpected non-zero byte 0x{} for {}", zero.toHexString(), command)
return
}
val code = buf.get().toInt() and 0xff
val controls = ShokzControls.fromCode(code)
if (controls == null) {
LOG.warn("Unknown controls code 0x{}", code.toHexString())
return
}
LOG.info("Got controls: {}", controls)
val eventUpdatePreferences = GBDeviceEventUpdatePreferences()
eventUpdatePreferences.withPreference(
DeviceSettingsPreferenceConst.PREF_SHOKZ_CONTROLS_LONG_PRESS_MULTI_FUNCTION,
when (controls) {
ShokzControls.ASSISTANT__MEDIA_SOURCE, ShokzControls.ASSISTANT__ASSISTANT -> "assistant"
ShokzControls.MEDIA_SOURCE__ASSISTANT, ShokzControls.MEDIA_SOURCE__MEDIA_SOURCE -> "media_source"
}
).withPreference(
DeviceSettingsPreferenceConst.PREF_SHOKZ_CONTROLS_SIMULTANEOUS_VOLUME_UP_DOWN,
when (controls) {
ShokzControls.ASSISTANT__MEDIA_SOURCE, ShokzControls.MEDIA_SOURCE__MEDIA_SOURCE -> "media_source"
ShokzControls.MEDIA_SOURCE__ASSISTANT, ShokzControls.ASSISTANT__ASSISTANT -> "assistant"
}
)
evaluateGBDeviceEvent(eventUpdatePreferences)
}
ShokzCommand.LANGUAGE_RET -> {
buf.getShort() // 0
val code = buf.get().toInt() and 0xff
val language = ShokzLanguage.fromCode(code)
if (language == null) {
LOG.warn("Unknown language code 0x{}", code.toHexString())
return
}
LOG.info("Got language: {}", language)
evaluateGBDeviceEvent(
GBDeviceEventUpdatePreferences(
DeviceSettingsPreferenceConst.PREF_LANGUAGE,
language.locale
)
)
}
ShokzCommand.MEDIA_SOURCE_NOTIFY -> {
val code = buf.get().toInt() and 0xff
val mediaSource = ShokzMediaSource.fromCode(code)
if (mediaSource == null) {
LOG.warn("Unknown media source 0x{}", code.toHexString())
return
}
LOG.info("Got media source change: {}", mediaSource)
evaluateGBDeviceEvent(
GBDeviceEventUpdatePreferences(
DeviceSettingsPreferenceConst.PREF_MEDIA_SOURCE,
mediaSource.name.lowercase()
)
)
}
else -> LOG.warn("Unhandled command {}, args={}", command, args.toHexString())
}
}
private fun queueCommand(
command: ShokzCommand,
args: ByteArray = byteArrayOf()
) {
messageQueue.add(ShokzMessage(command, args))
if (pendingMessage == null) {
sendNextCommand()
}
}
private fun onCommandTimeout() {
if (timeoutRetries++ < 3) {
LOG.warn("Timed out waiting for response, retrying attempt {}", timeoutRetries)
pendingMessage?.let { msg ->
sendMessage(msg)
return
}
}
LOG.warn("Timed out waiting for response, giving up")
sendNextCommand()
}
private fun sendNextCommand() {
timeoutHandler.removeCallbacksAndMessages(null)
timeoutRetries = 0
pendingMessage = messageQueue.poll()
pendingMessage?.let { msg ->
LOG.debug("Sending next command in queue: {}", msg.command)
sendMessage(msg)
return
}
LOG.debug("No more commands in the queue")
}
private fun sendMessage(message: ShokzMessage) {
val builder = createTransactionBuilder(message.command.name.lowercase())
builder.write(*encodeCommand(message.command, message.args))
builder.queue()
timeoutHandler.postDelayed({ onCommandTimeout() }, 2000L)
}
companion object {
private val LOG = LoggerFactory.getLogger(ShokzSupport::class.java)
private const val MAX_MTU: Int = 2048
private const val PACKET_PREAMBLE: Short = 0x5aa5.toShort() // LE
private const val MASK_RESPONSE: Int = 0x8000
// This does not show up in the discovered services?
private val UUID_SERVICE_SHOKZ: UUID = UUID.fromString("0000fef0-0000-1000-8000-00805f9b34fb")
/// Each command seems to have the following format (all u32 LE)
/// 0x01 length 0x02 0x04 group 0x03 0x04 command 0x04 args_length args
fun encodeCommand(
command: ShokzCommand,
args: ByteArray = byteArrayOf()
): ByteArray {
val buf = ByteBuffer.allocate(52 + args.size).order(ByteOrder.LITTLE_ENDIAN)
buf.putShort(PACKET_PREAMBLE)
buf.putShort((buf.limit() - 8).toShort())
buf.putInt(0x01)
buf.putShort((buf.limit() - 12).toShort())
// CRC will be added later
buf.position(buf.position() + 2)
buf.putInt(0x01)
buf.putInt(buf.limit() - 20)
buf.putInt(0x02)
buf.putInt(0x04)
buf.putInt(command.group)
buf.putInt(0x03)
buf.putInt(0x04)
buf.putInt(command.code)
buf.putInt(0x04)
buf.putInt(args.size)
buf.put(args)
buf.putShort(10, CheckSums.crc16_maxim(buf.array(), 12, buf.limit() - 12).toShort())
return buf.array()
}
}
}
@@ -46,7 +46,7 @@ import nodomain.freeyourgadget.gadgetbridge.util.GB;
public class XiaomiSppSupport extends XiaomiConnectionSupport {
private static final Logger LOG = LoggerFactory.getLogger(XiaomiSppSupport.class);
AbstractBTBRDeviceSupport commsSupport = new AbstractBTBRDeviceSupport(LOG) {
AbstractBTBRDeviceSupport commsSupport = new AbstractBTBRDeviceSupport(LOG, 1024) {
@Override
public boolean useAutoConnect() {
return mXiaomiSupport.useAutoConnect();
@@ -190,4 +190,24 @@ public class CheckSums {
md.update(str.getBytes(StandardCharsets.UTF_8));
return GB.hexdump(md.digest()).toLowerCase(Locale.ROOT);
}
public static int crc16_maxim(final byte[] data, final int offset, final int length) {
// Reflected polynomial 0x8005 -> 0xA001 when shifting right (LSB first)
final int poly = 0xA001;
int crc = 0x0000;
for (int i = offset; i < offset + length; i++) {
final byte b = data[i];
crc ^= (b & 0xFF);
for (int j = 0; j < 8; j++) {
if ((crc & 0x0001) != 0) {
crc = (crc >>> 1) ^ poly;
} else {
crc >>>= 1;
}
}
}
return crc ^ 0xFFFF;
}
}
@@ -0,0 +1,13 @@
package nodomain.freeyourgadget.gadgetbridge.util.kotlin
fun ByteArray.startsWith(prefix: ByteArray): Boolean {
if (prefix.size > this.size) {
return false
}
for (i in prefix.indices) {
if (this[i] != prefix[i]) {
return false
}
}
return true
}
@@ -0,0 +1,8 @@
package nodomain.freeyourgadget.gadgetbridge.util.kotlin
import nodomain.freeyourgadget.gadgetbridge.util.StringUtils
import java.nio.ByteBuffer
fun ByteBuffer.stringUntilNullTerminator(): String? {
return StringUtils.untilNullTerminator(this)
}
+49
View File
@@ -2708,6 +2708,7 @@
<item name="ms_MY">@string/bahasa_melayu</item>
<item name="fa_IR">@string/persian</item>
<!-- simplified versions, without a country -->
<item name="zh">@string/chinese</item>
<item name="en">@string/english</item>
<item name="ko">@string/korean</item>
<item name="ja">@string/japanese</item>
@@ -2773,6 +2774,7 @@
<item>ms_MY</item>
<item>fa_IR</item>
<!-- simplified versions, without a country -->
<item>zh</item>
<item>en</item>
<item>ko</item>
<item>ja</item>
@@ -4119,6 +4121,53 @@
<item>custom_2</item>
</string-array>
<string-array name="media_source_names">
<item>@string/menuitem_bluetooth</item>
<item>@string/mp3</item>
</string-array>
<string-array name="media_source_values">
<item>bluetooth</item>
<item>mp3</item>
</string-array>
<string-array name="shokz_equalizer_bluetooth_names">
<item>@string/equalizer_preset_standard</item>
<item>@string/sony_equalizer_preset_vocal</item>
</string-array>
<string-array name="shokz_equalizer_bluetooth_values">
<item>standard</item>
<item>vocal</item>
</string-array>
<string-array name="shokz_equalizer_mp3_names">
<item>@string/equalizer_preset_standard</item>
<item>@string/Swimming</item>
</string-array>
<string-array name="shokz_equalizer_mp3_values">
<item>standard</item>
<item>swimming</item>
</string-array>
<string-array name="media_playback_mode_names">
<item>@string/media_playback_mode_normal</item>
<item>@string/media_playback_mode_shuffle</item>
<item>@string/media_playback_mode_repeat</item>
</string-array>
<string-array name="media_playback_mode_values">
<item>normal</item>
<item>shuffle</item>
<item>repeat</item>
</string-array>
<string-array name="shokz_controls_names">
<item>@string/moondrop_touch_action_assistant</item>
<item>@string/media_source_switching_bluetooth_mp3</item>
</string-array>
<string-array name="shokz_controls_values">
<item>assistant</item>
<item>media_source</item>
</string-array>
<string-array name="sony_automatic_power_off_names">
<item>@string/sony_automatic_power_off_off</item>
<item>@string/sony_automatic_power_off_5_min</item>
+12
View File
@@ -1208,6 +1208,7 @@
<string name="manual">Manual</string>
<string name="simplified_chinese">Chinese (Simplified)</string>
<string name="traditional_chinese">Chinese (Traditional)</string>
<string name="chinese">Chinese</string>
<string name="english">English</string>
<string name="english_au">English (Australia)</string>
<string name="english_ca">English (Canada)</string>
@@ -4205,4 +4206,15 @@
<string name="help">Help</string>
<string name="cannot_open_help_url">Could not open URL</string>
<string name="auth_settings">Authentication settings</string>
<string name="devicetype_shokz_openswim_pro">Shokz OpenSwim Pro</string>
<string name="equalizer_preset_standard">Standard</string>
<string name="media_playback_mode">Playback mode</string>
<string name="media_playback_mode_normal">Normal</string>
<string name="media_playback_mode_shuffle">Shuffle</string>
<string name="media_playback_mode_repeat">Repeat</string>
<string name="media_source">Media source</string>
<string name="mp3">MP3</string>
<string name="media_source_switching_bluetooth_mp3">Switch media source (Bluetooth/MP3)</string>
<string name="shokz_controls_long_press_multi_function">Long-press the multi-function button</string>
<string name="shokz_controls_simultaneous_volume_up_down">Long-press volume up and down simultaneously</string>
</resources>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<ListPreference
android:defaultValue="off"
android:entries="@array/media_source_names"
android:entryValues="@array/media_source_values"
android:icon="@drawable/ic_music_note"
android:key="pref_media_source"
android:title="@string/media_source"
app:useSimpleSummaryProvider="true" />
</androidx.preference.PreferenceScreen>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<ListPreference
android:defaultValue="normal"
android:entries="@array/media_playback_mode_names"
android:entryValues="@array/media_playback_mode_values"
android:icon="@drawable/ic_play"
android:key="pref_media_playback_mode"
app:useSimpleSummaryProvider="true"
android:title="@string/media_playback_mode" />
</androidx.preference.PreferenceScreen>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<ListPreference
android:defaultValue=""
android:entries="@array/shokz_controls_names"
android:entryValues="@array/shokz_controls_values"
android:icon="@drawable/ic_touch"
android:key="shokz_controls_long_press_multi_function"
android:title="@string/shokz_controls_long_press_multi_function"
app:useSimpleSummaryProvider="true" />
<ListPreference
android:defaultValue=""
android:entries="@array/shokz_controls_names"
android:entryValues="@array/shokz_controls_values"
android:icon="@drawable/ic_arrows_up_down"
android:key="shokz_controls_simultaneous_volume_up_down"
android:title="@string/shokz_controls_simultaneous_volume_up_down"
app:useSimpleSummaryProvider="true" />
</androidx.preference.PreferenceScreen>
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<ListPreference
android:defaultValue="standard"
android:entries="@array/shokz_equalizer_bluetooth_names"
android:entryValues="@array/shokz_equalizer_bluetooth_values"
android:icon="@drawable/ic_equalizer"
android:key="pref_shokz_equalizer_bluetooth"
app:useSimpleSummaryProvider="true"
android:title="@string/sony_equalizer" />
<ListPreference
android:defaultValue="standard"
android:entries="@array/shokz_equalizer_mp3_names"
android:entryValues="@array/shokz_equalizer_mp3_values"
android:icon="@drawable/ic_equalizer"
android:key="pref_shokz_equalizer_mp3"
app:useSimpleSummaryProvider="true"
android:title="@string/sony_equalizer" />
</androidx.preference.PreferenceScreen>
@@ -0,0 +1,14 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.shokz
import org.junit.Assert.*
import org.junit.Test
class ShokzSupportTest {
@Test
fun testEncodeCommand() {
assertEquals(
"a55a2c000100000028005ab701000000200000000200000004000000020000000300000004000000020000000400000000000000",
ShokzSupport.encodeCommand(ShokzCommand.FIRMWARE_GET).toHexString()
)
}
}