mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
Sinilink: Initial support
Developed and tested against the XY-AP50L
This commit is contained in:
Generated
+1
@@ -157,6 +157,7 @@
|
|||||||
<w>shahrabani</w>
|
<w>shahrabani</w>
|
||||||
<w>shimokawa</w>
|
<w>shimokawa</w>
|
||||||
<w>shokz</w>
|
<w>shokz</w>
|
||||||
|
<w>sinilink</w>
|
||||||
<w>sint</w>
|
<w>sint</w>
|
||||||
<w>skype</w>
|
<w>skype</w>
|
||||||
<w>slezak</w>
|
<w>slezak</w>
|
||||||
|
|||||||
+1
@@ -170,6 +170,7 @@ public class DeviceSettingsPreferenceConst {
|
|||||||
public static final String PREF_ALWAYS_ON_DISPLAY_STYLE = "always_on_display_style";
|
public static final String PREF_ALWAYS_ON_DISPLAY_STYLE = "always_on_display_style";
|
||||||
|
|
||||||
public static final String PREF_VOLUME = "volume";
|
public static final String PREF_VOLUME = "volume";
|
||||||
|
public static final String PREF_PROMPT_TONE = "prompt_tone";
|
||||||
public static final String PREF_CROWN_VIBRATION = "crown_vibration";
|
public static final String PREF_CROWN_VIBRATION = "crown_vibration";
|
||||||
public static final String PREF_ALERT_TONE = "alert_tone";
|
public static final String PREF_ALERT_TONE = "alert_tone";
|
||||||
public static final String PREF_COVER_TO_MUTE = "cover_to_mute";
|
public static final String PREF_COVER_TO_MUTE = "cover_to_mute";
|
||||||
|
|||||||
+1
@@ -1001,6 +1001,7 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i
|
|||||||
|
|
||||||
addPreferenceHandlerFor(PREF_VOLUME);
|
addPreferenceHandlerFor(PREF_VOLUME);
|
||||||
addPreferenceHandlerFor(PREF_CROWN_VIBRATION);
|
addPreferenceHandlerFor(PREF_CROWN_VIBRATION);
|
||||||
|
addPreferenceHandlerFor(PREF_PROMPT_TONE);
|
||||||
addPreferenceHandlerFor(PREF_ALERT_TONE);
|
addPreferenceHandlerFor(PREF_ALERT_TONE);
|
||||||
addPreferenceHandlerFor(PREF_COVER_TO_MUTE);
|
addPreferenceHandlerFor(PREF_COVER_TO_MUTE);
|
||||||
addPreferenceHandlerFor(PREF_VIBRATE_FOR_ALERT);
|
addPreferenceHandlerFor(PREF_VIBRATE_FOR_ALERT);
|
||||||
|
|||||||
+1
-1
@@ -888,7 +888,7 @@ public class GBDeviceAdapterv2 extends ListAdapter<GBDevice, GBDeviceAdapterv2.V
|
|||||||
}
|
}
|
||||||
|
|
||||||
final String label = action.getLabel(device, context);
|
final String label = action.getLabel(device, context);
|
||||||
if (!StringUtils.isEmpty(label)) {
|
if (!StringUtils.isNullOrEmpty(label)) {
|
||||||
holder.customActions[i].label.setVisibility(View.VISIBLE);
|
holder.customActions[i].label.setVisibility(View.VISIBLE);
|
||||||
holder.customActions[i].label.setText(label);
|
holder.customActions[i].label.setText(label);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+17
-8
@@ -34,6 +34,7 @@ import nodomain.freeyourgadget.gadgetbridge.R;
|
|||||||
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
|
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler;
|
||||||
|
|
||||||
public class PasswordCapabilityImpl {
|
public class PasswordCapabilityImpl {
|
||||||
|
public static final String PREF_SCREEN_PASSWORD = "pref_screen_password";
|
||||||
public static final String PREF_PASSWORD = "pref_password";
|
public static final String PREF_PASSWORD = "pref_password";
|
||||||
public static final String PREF_PASSWORD_ENABLED = "pref_password_enabled";
|
public static final String PREF_PASSWORD_ENABLED = "pref_password_enabled";
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ public class PasswordCapabilityImpl {
|
|||||||
NUMBERS_4_DIGITS_0_TO_9,
|
NUMBERS_4_DIGITS_0_TO_9,
|
||||||
NUMBERS_4_DIGITS_1_TO_4,
|
NUMBERS_4_DIGITS_1_TO_4,
|
||||||
NUMBERS_6,
|
NUMBERS_6,
|
||||||
|
VISIBLE_NUMBERS_4_DIGITS_0_TO_9,
|
||||||
}
|
}
|
||||||
|
|
||||||
public void registerPreferences(final Context context, final Mode mode, final DeviceSpecificSettingsHandler handler) {
|
public void registerPreferences(final Context context, final Mode mode, final DeviceSpecificSettingsHandler handler) {
|
||||||
@@ -62,6 +64,7 @@ public class PasswordCapabilityImpl {
|
|||||||
password.setSummary(R.string.prefs_password_6_digits_0_to_9_summary);
|
password.setSummary(R.string.prefs_password_6_digits_0_to_9_summary);
|
||||||
break;
|
break;
|
||||||
case NUMBERS_4_DIGITS_0_TO_9:
|
case NUMBERS_4_DIGITS_0_TO_9:
|
||||||
|
case VISIBLE_NUMBERS_4_DIGITS_0_TO_9:
|
||||||
password.setSummary(R.string.prefs_password_4_digits_0_to_9_summary);
|
password.setSummary(R.string.prefs_password_4_digits_0_to_9_summary);
|
||||||
break;
|
break;
|
||||||
case NUMBERS_4_DIGITS_1_TO_4:
|
case NUMBERS_4_DIGITS_1_TO_4:
|
||||||
@@ -71,35 +74,42 @@ public class PasswordCapabilityImpl {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
password.setOnBindEditTextListener(new EditTextPreference.OnBindEditTextListener() {
|
password.setOnBindEditTextListener(editText -> {
|
||||||
@Override
|
|
||||||
public void onBindEditText(@NonNull final EditText editText) {
|
|
||||||
final int expectedLength;
|
final int expectedLength;
|
||||||
final List<InputFilter> inputFilters = new ArrayList<>();
|
final List<InputFilter> inputFilters = new ArrayList<>();
|
||||||
|
|
||||||
|
int inputType;
|
||||||
|
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case NUMBERS_6:
|
case NUMBERS_6:
|
||||||
password.setSummary(R.string.prefs_password_6_digits_0_to_9_summary);
|
password.setSummary(R.string.prefs_password_6_digits_0_to_9_summary);
|
||||||
editText.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD);
|
inputType = InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD;
|
||||||
expectedLength = 6;
|
expectedLength = 6;
|
||||||
break;
|
break;
|
||||||
case NUMBERS_4_DIGITS_0_TO_9:
|
case NUMBERS_4_DIGITS_0_TO_9:
|
||||||
password.setSummary(R.string.prefs_password_4_digits_0_to_9_summary);
|
password.setSummary(R.string.prefs_password_4_digits_0_to_9_summary);
|
||||||
editText.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD);
|
inputType = InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD;
|
||||||
|
expectedLength = 4;
|
||||||
|
break;
|
||||||
|
case VISIBLE_NUMBERS_4_DIGITS_0_TO_9:
|
||||||
|
password.setSummary(R.string.prefs_password_4_digits_0_to_9_summary);
|
||||||
|
inputType = InputType.TYPE_CLASS_NUMBER;
|
||||||
expectedLength = 4;
|
expectedLength = 4;
|
||||||
break;
|
break;
|
||||||
case NUMBERS_4_DIGITS_1_TO_4:
|
case NUMBERS_4_DIGITS_1_TO_4:
|
||||||
password.setSummary(R.string.prefs_password_4_digits_1_to_4_summary);
|
password.setSummary(R.string.prefs_password_4_digits_1_to_4_summary);
|
||||||
editText.setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD);
|
inputType = InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD;
|
||||||
expectedLength = 4;
|
expectedLength = 4;
|
||||||
inputFilters.add(new InputFilter_Digits_1to4());
|
inputFilters.add(new InputFilter_Digits_1to4());
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
editText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
inputType = InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD;
|
||||||
expectedLength = -1;
|
expectedLength = -1;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
editText.setInputType(inputType);
|
||||||
|
|
||||||
if (expectedLength != -1) {
|
if (expectedLength != -1) {
|
||||||
inputFilters.add(new InputFilter.LengthFilter(expectedLength));
|
inputFilters.add(new InputFilter.LengthFilter(expectedLength));
|
||||||
}
|
}
|
||||||
@@ -107,7 +117,6 @@ public class PasswordCapabilityImpl {
|
|||||||
editText.setSelection(editText.getText().length());
|
editText.setSelection(editText.getText().length());
|
||||||
editText.setFilters(inputFilters.toArray(new InputFilter[0]));
|
editText.setFilters(inputFilters.toArray(new InputFilter[0]));
|
||||||
editText.addTextChangedListener(new ExpectedLengthTextWatcher(editText, expectedLength));
|
editText.addTextChangedListener(new ExpectedLengthTextWatcher(editText, expectedLength));
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
package nodomain.freeyourgadget.gadgetbridge.devices.sinilink
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.GBApplication
|
||||||
|
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.capabilities.password.PasswordCapabilityImpl
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCardAction
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.service.devices.sinilink.SinilinkButton
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.service.devices.sinilink.SinilinkPlaybackState
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.service.devices.sinilink.SinilinkSupport
|
||||||
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
|
class SinilinkCoordinator : AbstractBLEDeviceCoordinator() {
|
||||||
|
override fun getSupportedDeviceName(): Pattern? {
|
||||||
|
return Pattern.compile("^Sinilink-APP$")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getManufacturer(): String {
|
||||||
|
return "Xinyi Electronics"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDeviceSupportClass(device: GBDevice): Class<out DeviceSupport?> {
|
||||||
|
return SinilinkSupport::class.java
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getBondingStyle(): Int {
|
||||||
|
// Does not seem to be needed?
|
||||||
|
return BONDING_STYLE_ASK
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun suggestUnbindBeforePair(): Boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDeviceNameResource(): Int {
|
||||||
|
return R.string.devicetype_sinilink
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDefaultIconResource(): Int {
|
||||||
|
return R.drawable.ic_device_speaker
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDeviceKind(device: GBDevice): DeviceCoordinator.DeviceKind? {
|
||||||
|
return DeviceCoordinator.DeviceKind.SPEAKER
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getBatteryCount(device: GBDevice): Int {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDeviceSpecificSettings(device: GBDevice): DeviceSpecificSettings {
|
||||||
|
val settings = DeviceSpecificSettings()
|
||||||
|
|
||||||
|
settings.addRootScreen(R.xml.devicesettings_sinilink)
|
||||||
|
settings.addRootScreen(R.xml.devicesettings_device_name)
|
||||||
|
settings.addRootScreen(R.xml.devicesettings_password)
|
||||||
|
|
||||||
|
settings.addConnectedPreferences(
|
||||||
|
DeviceSettingsPreferenceConst.PREF_MEDIA_SOURCE,
|
||||||
|
DeviceSettingsPreferenceConst.PREF_HEADPHONES_EQUALIZER,
|
||||||
|
DeviceSettingsPreferenceConst.PREF_MEDIA_PLAYBACK_MODE,
|
||||||
|
DeviceSettingsPreferenceConst.PREF_VOLUME,
|
||||||
|
DeviceSettingsPreferenceConst.PREF_PROMPT_TONE,
|
||||||
|
DeviceSettingsPreferenceConst.PREF_DEVICE_NAME,
|
||||||
|
PasswordCapabilityImpl.PREF_SCREEN_PASSWORD,
|
||||||
|
PasswordCapabilityImpl.PREF_PASSWORD_ENABLED,
|
||||||
|
PasswordCapabilityImpl.PREF_PASSWORD,
|
||||||
|
)
|
||||||
|
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDeviceSpecificSettingsCustomizer(device: GBDevice): DeviceSpecificSettingsCustomizer {
|
||||||
|
return SinilinkSettingsCustomizer()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getPasswordCapability(): PasswordCapabilityImpl.Mode {
|
||||||
|
return PasswordCapabilityImpl.Mode.VISIBLE_NUMBERS_4_DIGITS_0_TO_9
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCustomActions(): List<DeviceCardAction> {
|
||||||
|
return DEVICE_CARD_ACTIONS
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val DEVICE_CARD_ACTIONS = listOf(
|
||||||
|
// previous
|
||||||
|
object : DeviceCardAction {
|
||||||
|
override fun getIcon(device: GBDevice): Int {
|
||||||
|
return R.drawable.ic_skip_previous
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDescription(device: GBDevice, context: Context): String {
|
||||||
|
return context.getString(R.string.pref_media_previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClick(device: GBDevice, context: Context) {
|
||||||
|
GBApplication.deviceService(device).onSendConfiguration(SinilinkButton.PREVIOUS.name)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// play / pause
|
||||||
|
object : DeviceCardAction {
|
||||||
|
override fun getIcon(device: GBDevice): Int {
|
||||||
|
return when (SinilinkPlaybackState.fromPreference(device.getExtraInfo("playback_state") as? String ?: return R.drawable.ic_play)) {
|
||||||
|
SinilinkPlaybackState.PLAYING -> R.drawable.ic_pause
|
||||||
|
else -> R.drawable.ic_play
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDescription(device: GBDevice, context: Context): String {
|
||||||
|
return context.getString(R.string.moondrop_touch_action_play_pause)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClick(device: GBDevice, context: Context) {
|
||||||
|
GBApplication.deviceService(device).onSendConfiguration(SinilinkButton.PLAY_PAUSE.name)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// next
|
||||||
|
object : DeviceCardAction {
|
||||||
|
override fun getIcon(device: GBDevice): Int {
|
||||||
|
return R.drawable.ic_skip_next
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDescription(device: GBDevice, context: Context): String {
|
||||||
|
return context.getString(R.string.pref_media_next)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClick(device: GBDevice, context: Context) {
|
||||||
|
GBApplication.deviceService(device).onSendConfiguration(SinilinkButton.NEXT.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
/* Copyright (C) 2026 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.sinilink
|
||||||
|
|
||||||
|
import android.text.InputFilter
|
||||||
|
import android.text.InputFilter.LengthFilter
|
||||||
|
import android.widget.EditText
|
||||||
|
import androidx.preference.EditTextPreference
|
||||||
|
import androidx.preference.EditTextPreference.OnBindEditTextListener
|
||||||
|
import androidx.preference.Preference
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
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.capabilities.password.PasswordCapabilityImpl.PREF_PASSWORD_ENABLED
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl.PREF_SCREEN_PASSWORD
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.service.devices.earfun.prefs.EarFunSettingsPreferenceConst
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.util.Prefs
|
||||||
|
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
class SinilinkSettingsCustomizer : DeviceSpecificSettingsCustomizer {
|
||||||
|
override fun onPreferenceChange(preference: Preference, handler: DeviceSpecificSettingsHandler
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDeviceChanged(handler: DeviceSpecificSettingsHandler) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun customizeSettings(
|
||||||
|
handler: DeviceSpecificSettingsHandler,
|
||||||
|
genericDevicePrefs: Prefs,
|
||||||
|
rootKey: String?
|
||||||
|
) {
|
||||||
|
// Override the default summary if it is not a band or a watch
|
||||||
|
handler.findPreference<Preference>(PREF_SCREEN_PASSWORD)?.summary = null
|
||||||
|
handler.findPreference<Preference>(PREF_PASSWORD_ENABLED)?.summary = null
|
||||||
|
|
||||||
|
handler.findPreference<EditTextPreference>(DeviceSettingsPreferenceConst.PREF_DEVICE_NAME)?.let {
|
||||||
|
it.setOnBindEditTextListener { editText: EditText? ->
|
||||||
|
editText!!.setFilters(arrayOf<InputFilter>(LengthFilter(10)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getPreferenceKeysWithSummary(): Set<String> {
|
||||||
|
return setOf()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -405,6 +405,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.sbm_67.SanitasSBM67Coordinat
|
|||||||
import nodomain.freeyourgadget.gadgetbridge.devices.sbm_67.SilverCrestSBM67Coordinator;
|
import nodomain.freeyourgadget.gadgetbridge.devices.sbm_67.SilverCrestSBM67Coordinator;
|
||||||
import nodomain.freeyourgadget.gadgetbridge.devices.scannable.ScannableDeviceCoordinator;
|
import nodomain.freeyourgadget.gadgetbridge.devices.scannable.ScannableDeviceCoordinator;
|
||||||
import nodomain.freeyourgadget.gadgetbridge.devices.shokz.ShokzOpenSwimProCoordinator;
|
import nodomain.freeyourgadget.gadgetbridge.devices.shokz.ShokzOpenSwimProCoordinator;
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.devices.sinilink.SinilinkCoordinator;
|
||||||
import nodomain.freeyourgadget.gadgetbridge.devices.smaq2oss.SMAQ2OSSCoordinator;
|
import nodomain.freeyourgadget.gadgetbridge.devices.smaq2oss.SMAQ2OSSCoordinator;
|
||||||
import nodomain.freeyourgadget.gadgetbridge.devices.soflow.SoFlowCoordinator;
|
import nodomain.freeyourgadget.gadgetbridge.devices.soflow.SoFlowCoordinator;
|
||||||
import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyLinkBudsCoordinator;
|
import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyLinkBudsCoordinator;
|
||||||
@@ -819,6 +820,7 @@ public enum DeviceType {
|
|||||||
SONY_WH_CH720N(SonyWHCH720NCoordinator.class),
|
SONY_WH_CH720N(SonyWHCH720NCoordinator.class),
|
||||||
PIXEL_BUDS_A(PixelBudsACoordinator.class),
|
PIXEL_BUDS_A(PixelBudsACoordinator.class),
|
||||||
SHOKZ_OPENSWIM_PRO(ShokzOpenSwimProCoordinator.class),
|
SHOKZ_OPENSWIM_PRO(ShokzOpenSwimProCoordinator.class),
|
||||||
|
SINILINK(SinilinkCoordinator.class),
|
||||||
ONETOUCH(OneTouchCoordinator.class),
|
ONETOUCH(OneTouchCoordinator.class),
|
||||||
SOUNDCORE_LIBERTY3_PRO(SoundcoreLiberty3ProCoordinator.class),
|
SOUNDCORE_LIBERTY3_PRO(SoundcoreLiberty3ProCoordinator.class),
|
||||||
SOUNDCORE_LIBERTY4_NC(SoundcoreLiberty4NCCoordinator.class),
|
SOUNDCORE_LIBERTY4_NC(SoundcoreLiberty4NCCoordinator.class),
|
||||||
|
|||||||
+3
@@ -606,6 +606,7 @@ public class BleNamesResolver {
|
|||||||
mServices.put("8d53dc1d-1db7-4cd3-868b-8a527460aa84", "(Propr: SMP - Simple Management Protocol)");
|
mServices.put("8d53dc1d-1db7-4cd3-868b-8a527460aa84", "(Propr: SMP - Simple Management Protocol)");
|
||||||
mServices.put("6e40fff0-b5a3-f393-e0a9-e50e24dcca9e", "(Propr: NUS - Nordic UART Service)");
|
mServices.put("6e40fff0-b5a3-f393-e0a9-e50e24dcca9e", "(Propr: NUS - Nordic UART Service)");
|
||||||
mServices.put("de5bf728-d711-4e47-af26-65e3012a5dc7", "(Propr: Yawell Serial)");
|
mServices.put("de5bf728-d711-4e47-af26-65e3012a5dc7", "(Propr: Yawell Serial)");
|
||||||
|
mServices.put("0000ae00-0000-1000-8000-00805f9b34fb", "(Propr: Sinilink-APP)");
|
||||||
|
|
||||||
// source https://bitbucket.org/bluetooth-SIG/public/src/main/assigned_numbers/uuids/characteristic_uuids.yaml
|
// source https://bitbucket.org/bluetooth-SIG/public/src/main/assigned_numbers/uuids/characteristic_uuids.yaml
|
||||||
mCharacteristics.put("00002a00-0000-1000-8000-00805f9b34fb", "Device Name");
|
mCharacteristics.put("00002a00-0000-1000-8000-00805f9b34fb", "Device Name");
|
||||||
@@ -1202,6 +1203,8 @@ public class BleNamesResolver {
|
|||||||
mCharacteristics.put("6e400003-b5a3-f393-e0a9-e50e24dcca9e", "(Propr: Nordic UART RX)");
|
mCharacteristics.put("6e400003-b5a3-f393-e0a9-e50e24dcca9e", "(Propr: Nordic UART RX)");
|
||||||
mCharacteristics.put("de5bf729-d711-4e47-af26-65e3012a5dc7", "(Propr: Yawell Notify)");
|
mCharacteristics.put("de5bf729-d711-4e47-af26-65e3012a5dc7", "(Propr: Yawell Notify)");
|
||||||
mCharacteristics.put("de5bf72a-d711-4e47-af26-65e3012a5dc7", "(Propr: Yawell Write)");
|
mCharacteristics.put("de5bf72a-d711-4e47-af26-65e3012a5dc7", "(Propr: Yawell Write)");
|
||||||
|
mCharacteristics.put("0000ae04-0000-1000-8000-00805f9b34fb", "(Propr: Sinilink-APP RX)");
|
||||||
|
mCharacteristics.put("0000ae10-0000-1000-8000-00805f9b34fb", "(Propr: Sinilink-APP TX)");
|
||||||
|
|
||||||
mValueFormats.put(52, "32bit float");
|
mValueFormats.put(52, "32bit float");
|
||||||
mValueFormats.put(50, "16bit float");
|
mValueFormats.put(50, "16bit float");
|
||||||
|
|||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
package nodomain.freeyourgadget.gadgetbridge.service.devices.sinilink
|
||||||
|
|
||||||
|
enum class SinilinkEqualizer(val code: Int) {
|
||||||
|
NORMAL(0x09),
|
||||||
|
ROCK(0x0a),
|
||||||
|
POP(0x0b),
|
||||||
|
CLASSIC(0x0c),
|
||||||
|
JAZZ(0x0d),
|
||||||
|
COUNTRY(0x0e),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromPreference(value: String): SinilinkEqualizer? = entries.find { it.name == value.uppercase() }
|
||||||
|
fun fromCode(code: Int): SinilinkEqualizer? = entries.find { it.code == code }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class SinilinkPlaybackMode(val code: Int) {
|
||||||
|
SINGLE_HEAD(0x10),
|
||||||
|
SINGLE_CYCLE(0x11),
|
||||||
|
RANDOM(0x12),
|
||||||
|
ORDER(0x13),
|
||||||
|
LIST_CYCLE(0x0f),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromPreference(value: String): SinilinkPlaybackMode? = entries.find { it.name == value.uppercase() }
|
||||||
|
fun fromCode(code: Int): SinilinkPlaybackMode? = entries.find { it.code == code }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class SinilinkPlaybackState(val code: Int) {
|
||||||
|
IDLE(0x01),
|
||||||
|
PLAYING(0x02),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromPreference(value: String): SinilinkPlaybackState? = entries.find { it.name == value.uppercase() }
|
||||||
|
fun fromCode(code: Int): SinilinkPlaybackState? = entries.find { it.code == code }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class SinilinkMediaSource(val code: Int) {
|
||||||
|
TF(0x03),
|
||||||
|
USB(0x04),
|
||||||
|
BLUETOOTH(0x14),
|
||||||
|
AUDIO_CARD(0x15),
|
||||||
|
AUX(0x16),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromPreference(value: String): SinilinkMediaSource? = entries.find { it.name == value.uppercase() }
|
||||||
|
fun fromCode(code: Int): SinilinkMediaSource? = entries.find { it.code == code }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class SinilinkButton(val code: Int) {
|
||||||
|
PLAY_PAUSE(0x01),
|
||||||
|
PREVIOUS(0x07),
|
||||||
|
NEXT(0x08),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromPreference(value: String): SinilinkButton? = entries.find { it.name == value.uppercase() }
|
||||||
|
fun fromCode(code: Int): SinilinkButton? = entries.find { it.code == code }
|
||||||
|
}
|
||||||
|
}
|
||||||
+392
@@ -0,0 +1,392 @@
|
|||||||
|
package nodomain.freeyourgadget.gadgetbridge.service.devices.sinilink
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothGatt
|
||||||
|
import android.bluetooth.BluetoothGattCharacteristic
|
||||||
|
import androidx.core.text.isDigitsOnly
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.GBApplication
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLESingleDeviceSupport
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.service.devices.sinilink.SinilinkSupport.Companion.UUID_CHARACTERISTIC_SINILINK_RX
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.util.Prefs
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class SinilinkSupport : AbstractBTLESingleDeviceSupport(LOG) {
|
||||||
|
private var gotName = false
|
||||||
|
private var gotFirmware = false
|
||||||
|
private var gotStatus = false
|
||||||
|
private var gotEvent = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
addSupportedService(UUID_SERVICE_SINILINK)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun useAutoConnect(): Boolean = true
|
||||||
|
|
||||||
|
override fun initializeDevice(builder: TransactionBuilder): TransactionBuilder {
|
||||||
|
gotName = false
|
||||||
|
gotFirmware = false
|
||||||
|
gotStatus = false
|
||||||
|
gotEvent = false
|
||||||
|
|
||||||
|
builder.setDeviceState(GBDevice.State.INITIALIZING)
|
||||||
|
|
||||||
|
builder.notify(UUID_CHARACTERISTIC_SINILINK_RX, true)
|
||||||
|
|
||||||
|
// Request version
|
||||||
|
builder.write(UUID_CHARACTERISTIC_SINILINK_TX, *encodeFrame(byteArrayOf(CMD_VERSION.toByte())))
|
||||||
|
|
||||||
|
// Request status (volume, prompt tone, password)
|
||||||
|
builder.write(UUID_CHARACTERISTIC_SINILINK_TX, *encodeFrame(byteArrayOf(CMD_STATUS_GET.toByte())))
|
||||||
|
|
||||||
|
// Read the TX characteristic, this triggers an event (?)
|
||||||
|
builder.read(UUID_CHARACTERISTIC_SINILINK_TX)
|
||||||
|
|
||||||
|
// Request name
|
||||||
|
builder.write(UUID_CHARACTERISTIC_SINILINK_TX, *encodeFrame(byteArrayOf(CMD_NAME_GET.toByte())))
|
||||||
|
|
||||||
|
// Will be set to initialized once we receive the settings
|
||||||
|
|
||||||
|
return builder
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCharacteristicChanged(
|
||||||
|
gatt: BluetoothGatt,
|
||||||
|
characteristic: BluetoothGattCharacteristic,
|
||||||
|
value: ByteArray,
|
||||||
|
): Boolean {
|
||||||
|
if (characteristic.uuid == UUID_CHARACTERISTIC_SINILINK_RX) {
|
||||||
|
parseFrame(value)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.onCharacteristicChanged(gatt, characteristic, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCharacteristicRead(
|
||||||
|
gatt: BluetoothGatt,
|
||||||
|
characteristic: BluetoothGattCharacteristic,
|
||||||
|
value: ByteArray,
|
||||||
|
status: Int
|
||||||
|
): Boolean {
|
||||||
|
if (status == BluetoothGatt.GATT_SUCCESS && characteristic.uuid == UUID_CHARACTERISTIC_SINILINK_TX) {
|
||||||
|
parseFrame(value)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.onCharacteristicRead(gatt, characteristic, value, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseFrame(frame: ByteArray) {
|
||||||
|
if (frame.size < 5) {
|
||||||
|
LOG.warn("Frame too short: {}", frame.size)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame[0].toInt() and 0xff != FRAME_SOF) {
|
||||||
|
LOG.warn("Unexpected SOF: 0x{}", Integer.toHexString(frame[0].toInt() and 0xff))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val declaredLen = frame[1].toInt() and 0xff
|
||||||
|
if (declaredLen != frame.size) {
|
||||||
|
LOG.warn("Length mismatch: declared={}, actual={}", declaredLen, frame.size)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val checksumHiIdx = frame.size - 2
|
||||||
|
val calculated = frame.slice(0 until checksumHiIdx).sumOf { it.toInt() and 0xff } % 65536
|
||||||
|
val received = ((frame[checksumHiIdx].toInt() and 0xff) shl 8) or (frame[checksumHiIdx + 1].toInt() and 0xff)
|
||||||
|
if (calculated != received) {
|
||||||
|
LOG.warn(
|
||||||
|
"Checksum mismatch: calculated=0x{}, received=0x{}",
|
||||||
|
Integer.toHexString(calculated),
|
||||||
|
Integer.toHexString(received),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val payload = frame.sliceArray(2 until checksumHiIdx)
|
||||||
|
if (payload.isEmpty()) {
|
||||||
|
LOG.warn("Empty payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
handlePayload(payload)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LOG.error("Failed to handle payload", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gotName && gotStatus && gotFirmware && gotEvent && !device.isInitialized) {
|
||||||
|
device.state = GBDevice.State.INITIALIZED
|
||||||
|
device.sendDeviceUpdateIntent(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handlePayload(payload: ByteArray) {
|
||||||
|
LOG.debug("Got payload: {}", payload.toHexString())
|
||||||
|
|
||||||
|
when (val cmd = payload[0].toInt() and 0xff) {
|
||||||
|
CMD_STATUS_GET -> handleStatus(payload)
|
||||||
|
CMD_EVENT_NOTIFICATION_1, CMD_EVENT_NOTIFICATION_2 -> handleEvent(payload)
|
||||||
|
CMD_VERSION -> handleVersion(payload)
|
||||||
|
CMD_NAME_GET -> handleName(payload)
|
||||||
|
else -> {
|
||||||
|
LOG.debug("Unhandled notification command: 0x{}", Integer.toHexString(cmd))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleStatus(payload: ByteArray) {
|
||||||
|
if (payload.size != 12) {
|
||||||
|
LOG.warn("Status payload has unexpected size: {}", payload.size)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val promptTone = payload[1].toInt() and 0xff != 0
|
||||||
|
val passwordEnabled = payload[2].toInt() and 0xff != 0
|
||||||
|
val volume = payload[3].toInt() and 0xff
|
||||||
|
val passwordTxt = String(payload.sliceArray(4..7), Charsets.US_ASCII)
|
||||||
|
|
||||||
|
LOG.debug(
|
||||||
|
"Status: volume={}, promptTone={}, passwordEnabled={}, passwordTxt={}",
|
||||||
|
volume,
|
||||||
|
promptTone,
|
||||||
|
passwordEnabled,
|
||||||
|
passwordTxt
|
||||||
|
)
|
||||||
|
|
||||||
|
evaluateGBDeviceEvent(
|
||||||
|
GBDeviceEventUpdatePreferences()
|
||||||
|
.withPreference(DeviceSettingsPreferenceConst.PREF_VOLUME, volume)
|
||||||
|
.withPreference(DeviceSettingsPreferenceConst.PREF_PROMPT_TONE, promptTone)
|
||||||
|
.withPreference(PasswordCapabilityImpl.PREF_PASSWORD_ENABLED, passwordEnabled)
|
||||||
|
.withPreference(PasswordCapabilityImpl.PREF_PASSWORD, passwordTxt)
|
||||||
|
)
|
||||||
|
|
||||||
|
gotStatus = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleEvent(payload: ByteArray) {
|
||||||
|
if (payload.size != 11) {
|
||||||
|
LOG.warn("Unexpected event payload size: {}", payload.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
val sourceCode = payload[2].toInt() and 0xff
|
||||||
|
val playbackStateCode = payload[3].toInt() and 0xff
|
||||||
|
val playbackModeCode = payload[5].toInt() and 0xff
|
||||||
|
val equalizerCode = payload[6].toInt() and 0xff
|
||||||
|
|
||||||
|
val source = SinilinkMediaSource.fromCode(sourceCode)
|
||||||
|
val playbackState = SinilinkPlaybackState.fromCode(playbackStateCode)
|
||||||
|
val playbackMode = SinilinkPlaybackMode.fromCode(playbackModeCode)
|
||||||
|
val equalizer = SinilinkEqualizer.fromCode(equalizerCode)
|
||||||
|
|
||||||
|
LOG.debug(
|
||||||
|
"Event: source={}, playbackState={}, playbackMode={}, equalizer={}",
|
||||||
|
source,
|
||||||
|
playbackState,
|
||||||
|
playbackMode,
|
||||||
|
equalizer
|
||||||
|
)
|
||||||
|
|
||||||
|
val event = GBDeviceEventUpdatePreferences()
|
||||||
|
source?.let { event.withPreference(DeviceSettingsPreferenceConst.PREF_MEDIA_SOURCE, it.name.lowercase()) }
|
||||||
|
playbackState?.let { device.setExtraInfo("playback_state", it.name) }
|
||||||
|
playbackMode?.let {
|
||||||
|
event.withPreference(
|
||||||
|
DeviceSettingsPreferenceConst.PREF_MEDIA_PLAYBACK_MODE,
|
||||||
|
it.name.lowercase()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
equalizer?.let {
|
||||||
|
event.withPreference(
|
||||||
|
DeviceSettingsPreferenceConst.PREF_HEADPHONES_EQUALIZER,
|
||||||
|
it.name.lowercase()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
evaluateGBDeviceEvent(event)
|
||||||
|
|
||||||
|
// Propagate device state to device card
|
||||||
|
device.sendDeviceUpdateIntent(context)
|
||||||
|
|
||||||
|
val version = String(payload.sliceArray(7 until 11), Charsets.US_ASCII)
|
||||||
|
val versionEvent = GBDeviceEventVersionInfo()
|
||||||
|
versionEvent.fwVersion = version
|
||||||
|
evaluateGBDeviceEvent(versionEvent)
|
||||||
|
gotEvent = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleVersion(payload: ByteArray) {
|
||||||
|
if (payload.size != 5) {
|
||||||
|
LOG.warn("Unexpected version payload size: {}", payload.size)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val version = String(payload.sliceArray(1 until 5), Charsets.US_ASCII)
|
||||||
|
LOG.debug("Version: {}", version)
|
||||||
|
val event = GBDeviceEventVersionInfo()
|
||||||
|
event.fwVersion = version
|
||||||
|
evaluateGBDeviceEvent(event)
|
||||||
|
gotFirmware = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleName(payload: ByteArray) {
|
||||||
|
if (payload.size < 11) {
|
||||||
|
LOG.warn("Unexpected name payload size: {}", payload.size)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val name = String(payload.sliceArray(1 until 11), Charsets.US_ASCII).trimEnd('\u0000')
|
||||||
|
LOG.debug("Device name: {}", name)
|
||||||
|
evaluateGBDeviceEvent(
|
||||||
|
GBDeviceEventUpdatePreferences()
|
||||||
|
.withPreference(DeviceSettingsPreferenceConst.PREF_DEVICE_NAME, name)
|
||||||
|
)
|
||||||
|
gotName = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSendConfiguration(config: String) {
|
||||||
|
val prefs = Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.address))
|
||||||
|
when (config) {
|
||||||
|
SinilinkButton.PREVIOUS.name,
|
||||||
|
SinilinkButton.PLAY_PAUSE.name,
|
||||||
|
SinilinkButton.NEXT.name -> {
|
||||||
|
val button = SinilinkButton.valueOf(config)
|
||||||
|
LOG.debug("Sending button: {}", button)
|
||||||
|
sendSimpleCommand(button.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceSettingsPreferenceConst.PREF_HEADPHONES_EQUALIZER -> {
|
||||||
|
val value = prefs.getString(
|
||||||
|
DeviceSettingsPreferenceConst.PREF_HEADPHONES_EQUALIZER,
|
||||||
|
SinilinkEqualizer.NORMAL.name.lowercase()
|
||||||
|
)
|
||||||
|
LOG.debug("Setting equalizer to {}", value)
|
||||||
|
SinilinkEqualizer.fromPreference(value)?.let { sendSimpleCommand(it.code) }
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceSettingsPreferenceConst.PREF_MEDIA_PLAYBACK_MODE -> {
|
||||||
|
val value = prefs.getString(
|
||||||
|
DeviceSettingsPreferenceConst.PREF_MEDIA_PLAYBACK_MODE,
|
||||||
|
SinilinkPlaybackMode.SINGLE_HEAD.name.lowercase()
|
||||||
|
)
|
||||||
|
LOG.debug("Setting playback mode to {}", value)
|
||||||
|
SinilinkPlaybackMode.fromPreference(value)?.let { sendSimpleCommand(it.code) }
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceSettingsPreferenceConst.PREF_MEDIA_SOURCE -> {
|
||||||
|
val value = prefs.getString(
|
||||||
|
DeviceSettingsPreferenceConst.PREF_MEDIA_SOURCE,
|
||||||
|
SinilinkMediaSource.BLUETOOTH.name.lowercase()
|
||||||
|
)
|
||||||
|
LOG.debug("Setting media source to {}", value)
|
||||||
|
SinilinkMediaSource.fromPreference(value)?.let { sendSimpleCommand(it.code) }
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceSettingsPreferenceConst.PREF_VOLUME -> {
|
||||||
|
val value = prefs.getInt(DeviceSettingsPreferenceConst.PREF_VOLUME, 10).coerceIn(0, 30)
|
||||||
|
LOG.debug("Setting volume to {}", value)
|
||||||
|
sendVolumeCommand(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceSettingsPreferenceConst.PREF_PROMPT_TONE -> {
|
||||||
|
LOG.debug("Toggling prompt tone")
|
||||||
|
sendSimpleCommand(CMD_PROMPT_TONE_TOGGLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceSettingsPreferenceConst.PREF_DEVICE_NAME -> {
|
||||||
|
val value = prefs.getString(DeviceSettingsPreferenceConst.PREF_DEVICE_NAME, "XinYi").replace("[^\\x20-\\x7e]", "")
|
||||||
|
LOG.debug("Setting device name to {}", value)
|
||||||
|
if (value.isEmpty() || value.length > 10) {
|
||||||
|
LOG.warn("Invalid name: {}", value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
LOG.debug("Setting name to {}", value)
|
||||||
|
sendTextCommand(CMD_NAME_SET.toByte(), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
PasswordCapabilityImpl.PREF_PASSWORD_ENABLED -> {
|
||||||
|
LOG.debug("Toggling password")
|
||||||
|
sendSimpleCommand(CMD_PASSWORD_TOGGLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
PasswordCapabilityImpl.PREF_PASSWORD -> {
|
||||||
|
val value = prefs.getString(PasswordCapabilityImpl.PREF_PASSWORD, "1234")
|
||||||
|
if (value.length != 4 || !value.isDigitsOnly()) {
|
||||||
|
LOG.warn("Invalid password: {}", value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
LOG.debug("Setting password to {}", value)
|
||||||
|
sendTextCommand(CMD_PASSWORD_SET.toByte(), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> super.onSendConfiguration(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendSimpleCommand(cmd: Int) {
|
||||||
|
val builder = createTransactionBuilder("sinilink_cmd_0x${Integer.toHexString(cmd)}")
|
||||||
|
builder.write(UUID_CHARACTERISTIC_SINILINK_TX, *encodeFrame(byteArrayOf(cmd.toByte())))
|
||||||
|
builder.queue()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendVolumeCommand(volume: Int) {
|
||||||
|
val payload = ByteArray(11)
|
||||||
|
payload[0] = CMD_SET_VOLUME.toByte()
|
||||||
|
payload[1] = volume.toByte()
|
||||||
|
val builder = createTransactionBuilder("sinilink_set_volume")
|
||||||
|
builder.write(UUID_CHARACTERISTIC_SINILINK_TX, *encodeFrame(payload))
|
||||||
|
builder.queue()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendTextCommand(command: Byte, txt: String) {
|
||||||
|
val buf = ByteBuffer.allocate(11)
|
||||||
|
buf.put(command)
|
||||||
|
buf.put(txt.encodeToByteArray())
|
||||||
|
val builder = createTransactionBuilder("sinilink_set_text")
|
||||||
|
builder.write(UUID_CHARACTERISTIC_SINILINK_TX, *encodeFrame(buf.array()))
|
||||||
|
builder.queue()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val LOG = LoggerFactory.getLogger(SinilinkSupport::class.java)
|
||||||
|
|
||||||
|
private const val FRAME_SOF = 0x7e
|
||||||
|
|
||||||
|
private const val CMD_EVENT_NOTIFICATION_1 = 0xa8
|
||||||
|
private const val CMD_NAME_GET = 0x20
|
||||||
|
private const val CMD_NAME_SET = 0x1a
|
||||||
|
private const val CMD_PASSWORD_SET = 0x1b
|
||||||
|
private const val CMD_SET_VOLUME = 0x1d
|
||||||
|
private const val CMD_VERSION = 0x1e
|
||||||
|
private const val CMD_STATUS_GET = 0x1f
|
||||||
|
private const val CMD_EVENT_NOTIFICATION_2 = 0xc3
|
||||||
|
private const val CMD_PROMPT_TONE_TOGGLE = 0x18
|
||||||
|
private const val CMD_PASSWORD_TOGGLE = 0x19
|
||||||
|
|
||||||
|
val UUID_SERVICE_SINILINK: UUID = UUID.fromString("0000ae00-0000-1000-8000-00805f9b34fb")
|
||||||
|
val UUID_CHARACTERISTIC_SINILINK_RX: UUID = UUID.fromString("0000ae04-0000-1000-8000-00805f9b34fb")
|
||||||
|
val UUID_CHARACTERISTIC_SINILINK_TX: UUID = UUID.fromString("0000ae10-0000-1000-8000-00805f9b34fb")
|
||||||
|
|
||||||
|
/// Frame format: [0x7e][total_length][payload...][checksum_hi][checksum_lo]
|
||||||
|
/// Checksum = sum of all bytes before checksum, mod 65536, big-endian
|
||||||
|
fun encodeFrame(payload: ByteArray): ByteArray {
|
||||||
|
val totalLen = 2 + payload.size + 2 // SOF + len + payload + checksum(2)
|
||||||
|
val frame = ByteArray(totalLen)
|
||||||
|
frame[0] = FRAME_SOF.toByte()
|
||||||
|
frame[1] = totalLen.toByte()
|
||||||
|
payload.copyInto(frame, 2)
|
||||||
|
val checksum = frame.slice(0 until (totalLen - 2)).sumOf { it.toInt() and 0xff } % 65536
|
||||||
|
frame[totalLen - 2] = (checksum shr 8).toByte()
|
||||||
|
frame[totalLen - 1] = (checksum and 0xff).toByte()
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="45sp"
|
||||||
|
android:height="45sp"
|
||||||
|
android:viewportWidth="30"
|
||||||
|
android:viewportHeight="30">
|
||||||
|
<path
|
||||||
|
android:fillColor="?attr/deviceIconLight"
|
||||||
|
android:pathData="M3.871 3.877h20.925a0.947 0.947 0 0 1 0.948 0.947v20.01a0.947 0.947 0 0 1-0.948 0.948H3.871a0.947 0.947 0 0 1-0.947-0.948V4.824a0.947 0.947 0 0 1 0.947-0.947z"
|
||||||
|
android:strokeWidth="3.57115" />
|
||||||
|
<path
|
||||||
|
android:fillColor="?attr/deviceIconDark"
|
||||||
|
android:pathData="M3.879 3.035h20.925a0.947 0.947 0 0 1 0.947 0.947v20.01a0.947 0.947 0 0 1-0.947 0.948H3.88a0.947 0.947 0 0 1-0.947-0.948V3.982A0.947 0.947 0 0 1 3.88 3.035z"
|
||||||
|
android:strokeWidth="3.57115" />
|
||||||
|
<path
|
||||||
|
android:fillColor="?attr/deviceIconPrimary"
|
||||||
|
android:pathData="M3.871 3.413h20.925a0.947 0.947 0 0 1 0.948 0.947v20.01a0.947 0.947 0 0 1-0.948 0.948H3.871a0.947 0.947 0 0 1-0.947-0.948V4.36A0.947 0.947 0 0 1 3.87 3.413z"
|
||||||
|
android:strokeWidth="3.57115" />
|
||||||
|
<!-- Speaker body + cone, centered horizontally at ~14.3, vertically at 15 -->
|
||||||
|
<path
|
||||||
|
android:fillColor="?attr/deviceIconOnPrimary"
|
||||||
|
android:pathData="M 6,11 L 11,11 L 16,7 L 16,23 L 11,19 L 6,19 Z"
|
||||||
|
android:strokeWidth="1" />
|
||||||
|
<!-- Small sound wave crescent (outer r=3, inner r=2.2, centered at 17.5,15) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="?attr/deviceIconOnPrimary"
|
||||||
|
android:pathData="M 17.5,12 A 3,3 0 0 1 17.5,18 L 17.5,17.2 A 2.2,2.2 0 0 0 17.5,12.8 Z"
|
||||||
|
android:strokeWidth="1" />
|
||||||
|
<!-- Medium sound wave crescent (outer r=5, inner r=4.2, centered at 17.5,15) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="?attr/deviceIconOnPrimary"
|
||||||
|
android:pathData="M 17.5,10 A 5,5 0 0 1 17.5,20 L 17.5,19.2 A 4.2,4.2 0 0 0 17.5,10.8 Z"
|
||||||
|
android:strokeWidth="1" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<!--
|
||||||
|
~ Copyright (C) 2026 The Android Open Source Project
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:tint="?attr/colorControlNormal"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M660,720v-480h80v480h-80ZM220,720v-480l360,240 -360,240ZM300,480ZM300,570 L436,480 300,390v180Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<!--
|
||||||
|
~ Copyright (C) 2026 The Android Open Source Project
|
||||||
|
~
|
||||||
|
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
~ you may not use this file except in compliance with the License.
|
||||||
|
~ You may obtain a copy of the License at
|
||||||
|
~
|
||||||
|
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
~
|
||||||
|
~ Unless required by applicable law or agreed to in writing, software
|
||||||
|
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
~ See the License for the specific language governing permissions and
|
||||||
|
~ limitations under the License.
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:tint="?attr/colorControlNormal"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M220,720v-480h80v480h-80ZM740,720L380,480l360,-240v480ZM660,480ZM660,570v-180l-136,90 136,90Z" />
|
||||||
|
</vector>
|
||||||
@@ -5712,4 +5712,51 @@
|
|||||||
<item>MUSIC_CONTROL</item>
|
<item>MUSIC_CONTROL</item>
|
||||||
<item>RING_PHONE</item>
|
<item>RING_PHONE</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
|
|
||||||
|
<string-array name="sinilink_equalizer_names">
|
||||||
|
<item>@string/pref_title_equalizer_normal</item>
|
||||||
|
<item>@string/nothing_equalizer_rock</item>
|
||||||
|
<item>@string/nothing_equalizer_pop</item>
|
||||||
|
<item>@string/nothing_equalizer_classical</item>
|
||||||
|
<item>@string/soundcore_equalizer_preset_jazz</item>
|
||||||
|
<item>@string/equalizer_preset_country</item>
|
||||||
|
</string-array>
|
||||||
|
<string-array name="sinilink_equalizer_values">
|
||||||
|
<item>normal</item>
|
||||||
|
<item>rock</item>
|
||||||
|
<item>pop</item>
|
||||||
|
<item>classic</item>
|
||||||
|
<item>jazz</item>
|
||||||
|
<item>country</item>
|
||||||
|
</string-array>
|
||||||
|
|
||||||
|
<string-array name="sinilink_playback_mode_names">
|
||||||
|
<item>@string/sinilink_playback_mode_list_cycle</item>
|
||||||
|
<item>@string/sinilink_playback_mode_single_head</item>
|
||||||
|
<item>@string/sinilink_playback_mode_single_cycle</item>
|
||||||
|
<item>@string/sinilink_playback_mode_random</item>
|
||||||
|
<item>@string/sinilink_playback_mode_order</item>
|
||||||
|
</string-array>
|
||||||
|
<string-array name="sinilink_playback_mode_values">
|
||||||
|
<item>list_cycle</item>
|
||||||
|
<item>single_head</item>
|
||||||
|
<item>single_cycle</item>
|
||||||
|
<item>random</item>
|
||||||
|
<item>order</item>
|
||||||
|
</string-array>
|
||||||
|
|
||||||
|
<string-array name="sinilink_media_source_names">
|
||||||
|
<item>@string/media_source_tf</item>
|
||||||
|
<item>@string/media_source_usb</item>
|
||||||
|
<item>@string/menuitem_bluetooth</item>
|
||||||
|
<item>@string/media_source_audio_card</item>
|
||||||
|
<item>@string/media_source_aux</item>
|
||||||
|
</string-array>
|
||||||
|
<string-array name="sinilink_media_source_values">
|
||||||
|
<item>tf</item>
|
||||||
|
<item>usb</item>
|
||||||
|
<item>bluetooth</item>
|
||||||
|
<item>audio_card</item>
|
||||||
|
<item>aux</item>
|
||||||
|
</string-array>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -3544,6 +3544,7 @@
|
|||||||
<string name="soundcore_equalizer_preset_lounge">Lounge</string>
|
<string name="soundcore_equalizer_preset_lounge">Lounge</string>
|
||||||
<string name="soundcore_equalizer_preset_piano">Piano</string>
|
<string name="soundcore_equalizer_preset_piano">Piano</string>
|
||||||
<string name="soundcore_equalizer_preset_rnb">R&B</string>
|
<string name="soundcore_equalizer_preset_rnb">R&B</string>
|
||||||
|
<string name="equalizer_preset_country">Country</string>
|
||||||
<string name="soundcore_equalizer_preset_small_speakers">Small Speakers</string>
|
<string name="soundcore_equalizer_preset_small_speakers">Small Speakers</string>
|
||||||
<string name="soundcore_equalizer_preset_spoken_word">Spoken Word</string>
|
<string name="soundcore_equalizer_preset_spoken_word">Spoken Word</string>
|
||||||
<string name="soundcore_equalizer_preset_treble_reducer">Treble Reducer</string>
|
<string name="soundcore_equalizer_preset_treble_reducer">Treble Reducer</string>
|
||||||
@@ -4630,6 +4631,7 @@
|
|||||||
<string name="cannot_open_help_url">Could not open URL</string>
|
<string name="cannot_open_help_url">Could not open URL</string>
|
||||||
<string name="auth_settings">Authentication settings</string>
|
<string name="auth_settings">Authentication settings</string>
|
||||||
<string name="devicetype_shokz_openswim_pro" translatable="false">Shokz OpenSwim Pro</string>
|
<string name="devicetype_shokz_openswim_pro" translatable="false">Shokz OpenSwim Pro</string>
|
||||||
|
<string name="devicetype_sinilink" translatable="false">Sinilink</string>
|
||||||
<string name="devicetype_onetouch" translatable="false">OneTouch Glucose Meter</string>
|
<string name="devicetype_onetouch" translatable="false">OneTouch Glucose Meter</string>
|
||||||
<string name="equalizer_preset_standard">Standard</string>
|
<string name="equalizer_preset_standard">Standard</string>
|
||||||
<string name="media_playback_mode">Playback mode</string>
|
<string name="media_playback_mode">Playback mode</string>
|
||||||
@@ -4895,4 +4897,14 @@
|
|||||||
<string name="broadcast_heart_rate_summary">Any nearby device or app can pick up your heart rate.</string>
|
<string name="broadcast_heart_rate_summary">Any nearby device or app can pick up your heart rate.</string>
|
||||||
<string name="dual_connection_title">Dual connection</string>
|
<string name="dual_connection_title">Dual connection</string>
|
||||||
<string name="dual_connection_summary">Allow two apps or devices to connect simultaneously.</string>
|
<string name="dual_connection_summary">Allow two apps or devices to connect simultaneously.</string>
|
||||||
|
<string name="sinilink_playback_mode_list_cycle">List cycle</string>
|
||||||
|
<string name="sinilink_playback_mode_single_head">Single (head)</string>
|
||||||
|
<string name="sinilink_playback_mode_single_cycle">Single cycle</string>
|
||||||
|
<string name="sinilink_playback_mode_random">Random</string>
|
||||||
|
<string name="sinilink_playback_mode_order">Order</string>
|
||||||
|
<string name="media_source_tf">TF card</string>
|
||||||
|
<string name="media_source_usb">USB</string>
|
||||||
|
<string name="media_source_audio_card">Audio card</string>
|
||||||
|
<string name="media_source_aux">AUX</string>
|
||||||
|
<string name="sinilink_prompt_tone">Prompt tone</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?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="bluetooth"
|
||||||
|
android:entries="@array/sinilink_media_source_names"
|
||||||
|
android:entryValues="@array/sinilink_media_source_values"
|
||||||
|
android:icon="@drawable/ic_music_note"
|
||||||
|
android:key="pref_media_source"
|
||||||
|
android:title="@string/media_source"
|
||||||
|
app:useSimpleSummaryProvider="true" />
|
||||||
|
|
||||||
|
<ListPreference
|
||||||
|
android:defaultValue="normal"
|
||||||
|
android:entries="@array/sinilink_equalizer_names"
|
||||||
|
android:entryValues="@array/sinilink_equalizer_values"
|
||||||
|
android:icon="@drawable/ic_equalizer"
|
||||||
|
android:key="pref_headphones_equalizer"
|
||||||
|
android:title="@string/prefs_equalizer_preset"
|
||||||
|
app:useSimpleSummaryProvider="true" />
|
||||||
|
|
||||||
|
<ListPreference
|
||||||
|
android:defaultValue="list_cycle"
|
||||||
|
android:entries="@array/sinilink_playback_mode_names"
|
||||||
|
android:entryValues="@array/sinilink_playback_mode_values"
|
||||||
|
android:icon="@drawable/ic_play"
|
||||||
|
android:key="pref_media_playback_mode"
|
||||||
|
android:title="@string/media_playback_mode"
|
||||||
|
app:useSimpleSummaryProvider="true" />
|
||||||
|
|
||||||
|
<SeekBarPreference
|
||||||
|
android:defaultValue="15"
|
||||||
|
android:icon="@drawable/ic_volume_up"
|
||||||
|
android:key="volume"
|
||||||
|
android:max="30"
|
||||||
|
android:title="@string/menuitem_volume"
|
||||||
|
app:showSeekBarValue="true" />
|
||||||
|
|
||||||
|
<SwitchPreferenceCompat
|
||||||
|
android:defaultValue="true"
|
||||||
|
android:icon="@drawable/ic_notifications"
|
||||||
|
android:key="prompt_tone"
|
||||||
|
android:title="@string/sinilink_prompt_tone" />
|
||||||
|
|
||||||
|
</androidx.preference.PreferenceScreen>
|
||||||
+1
@@ -86,6 +86,7 @@ public class AbstractDeviceCoordinatorTest extends TestBase {
|
|||||||
put("R50Pro", DeviceType.R50PRO);
|
put("R50Pro", DeviceType.R50PRO);
|
||||||
put("SBM67", DeviceType.SILVERCREST_SBM_67);
|
put("SBM67", DeviceType.SILVERCREST_SBM_67);
|
||||||
put("BPM Smart", DeviceType.SANITAS_SBM_67);
|
put("BPM Smart", DeviceType.SANITAS_SBM_67);
|
||||||
|
put("Sinilink-APP", DeviceType.SINILINK); // #6040
|
||||||
put("R11_0500", DeviceType.YAWELL_R11); // #4711
|
put("R11_0500", DeviceType.YAWELL_R11); // #4711
|
||||||
put("Edge 2x", DeviceType.GARMIN_EDGE_25); // #5779
|
put("Edge 2x", DeviceType.GARMIN_EDGE_25); // #5779
|
||||||
put("Edge 130", DeviceType.GARMIN_EDGE_130); // matrix
|
put("Edge 130", DeviceType.GARMIN_EDGE_130); // matrix
|
||||||
|
|||||||
Reference in New Issue
Block a user