diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundbrenner/SoundbrennerConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundbrenner/SoundbrennerConstants.java new file mode 100644 index 0000000000..d495b3fe1a --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundbrenner/SoundbrennerConstants.java @@ -0,0 +1,189 @@ +package nodomain.freeyourgadget.gadgetbridge.devices.soundbrenner; + +import java.util.UUID; + +/** + * BLE UUIDs and protocol constants for the Soundbrenner Core. + * Reverse-engineered using nRF Connect + HCI Snoop Log (May/June 2026). + * + * === 32-byte packet structure for UUID_CHARACTERISTIC_CONFIG === + * + * Bytes 0-3: BPM as IEEE 754 float, big-endian + * + * Byte 4: Bytes 4: time signature + * Upper nibble = numerator - 1 + * Lower nibble = log2(denominator) + * Examples: 4/4=0x32, 3/4=0x22, 3/8=0x23, 5/4=0x42 + * + * Byte 5: beat strengths beats 1-4, 2 bits per beat MSB-first + * Byte 6: beat strengths beats 5-8; unused slots = 0x00 (wire default) + * Inverted encoding: UI 0->11, 1->00, 2->01, 3->10 + * Example (3:2:1:0) -> 10 01 00 11 = 0x93 + * + * Byte 7: unused, fixed 0x00 + * + * Byte 8: 0x00 (fixed) + * + * Bytes 9-10: Subdivision type constant, 16-bit big-endian. + * Formula: (subdivisionSteps - 1) * (9352 + 2 * numerator) + * 4/4 examples: eighth=0x2490, triplet=0x4920, sixteenth=0x6DB0 + * 5/4 examples: eighth=0x2492, triplet=0x4924, sixteenth=0x6DB6 + * + * Bytes 11-14: 0x00 (fixed) + * + * Bytes 15-(15+numerator-1): Beat pattern byte, one per beat. + * Bits 7-4 = active subdivision steps (bit 7=step1 .. bit 4=step4) + * Bits 3-0 = always 0000 + * Examples: quarter=0x80, both eighths=0xC0, all triplets=0xE0, + * all sixteenths=0xF0 + * + * Bytes (15+numerator)-30: 0x00 (inactive beat slots) + * + * Byte 31: 0x00 (footer) + */ +public final class SoundbrennerConstants { + + private SoundbrennerConstants() {} + + // ------------------------------------------------------------------------- + // Services + // ------------------------------------------------------------------------- + + public static final UUID UUID_SERVICE_METRONOME = + UUID.fromString("f3f6ce01-b257-4336-acd8-3010817837e4"); + public static final UUID UUID_SERVICE_VIBRATION = + UUID.fromString("77ef8bb5-7d4d-4a66-bd64-3f93cc01ccda"); + /** Unknown service – retained for completeness, not yet used. */ + public static final UUID UUID_SERVICE_UNKNOWN_A = + UUID.fromString("af7da518-f5f6-46d7-8ed1-546c33daf83b"); + /** Unknown service – retained for completeness, not yet used. */ + public static final UUID UUID_SERVICE_UNKNOWN_B = + UUID.fromString("c8befab9-5cc3-4af1-9102-394c40987d1e"); + /** Unknown service – probably used for handshake. */ + public static final UUID UUID_CHARACTERISTIC_UNKNOWN_CTRL = + UUID.fromString("061a5b49-8c3a-561e-9f34-dae895a3477a"); + //** Standard BLE Battery Service (0x180F / 0x2A19) */ + public static final UUID UUID_SERVICE_BATTERY = + UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb"); + public static final UUID UUID_CHARACTERISTIC_BATTERY_LEVEL = + UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb"); + + // ------------------------------------------------------------------------- + // Characteristics + // ------------------------------------------------------------------------- + + /** + * READ + WRITE + NOTIFY. + * Write: 32-byte configuration packet (BPM, time signature, beat strengths, subdivision). + * Notify: device pushes the same packet when the user changes settings on the watch. + */ + public static final UUID UUID_CHARACTERISTIC_CONFIG = + UUID.fromString("5ae0ba81-1053-4183-826e-7a6be885c142"); + + /** + * WRITE + INDICATE. + * Start metronome: {@link #CMD_METRONOME_START}. + * Stop metronome: {@link #CMD_METRONOME_STOP}. + */ + public static final UUID UUID_CHARACTERISTIC_CONTROL = + UUID.fromString("920475b8-17ee-4474-a500-35037152b860"); + + /** + * WRITE + INDICATE. + * Continuous vibration – exact payload still under investigation. + */ + public static final UUID UUID_CHARACTERISTIC_VIBRATION = + UUID.fromString("252aeb30-c6ec-43ec-8072-5b08f13dae36"); + + /** + * READ + WRITE. + * Unknown purpose; the official app reads this before every config write. + */ + public static final UUID UUID_CHARACTERISTIC_UNKNOWN_RW = + UUID.fromString("203b9729-4d3f-4898-8fb1-1ed1e6759229"); + + /** + * READ + WRITE + NOTIFY. + * Unknown purpose; lives inside the metronome service. + */ + public static final UUID UUID_CHARACTERISTIC_UNKNOWN_NOTIFY = + UUID.fromString("05094f38-7d29-450d-8f23-c9d784923668"); + + // ------------------------------------------------------------------------- + // Control commands + // ------------------------------------------------------------------------- + + public static final byte CMD_METRONOME_START = 0x01; + public static final byte CMD_METRONOME_STOP = 0x02; + /** magic byte **/ + public static final byte[] CMD_PRE_START = new byte[]{ (byte) 0xd1 }; + + // ------------------------------------------------------------------------- + // Packet layout constants + // ------------------------------------------------------------------------- + + public static final int PACKET_SIZE = 32; + + // ------------------------------------------------------------------------- + // Subdivision beat-pattern bytes + // ------------------------------------------------------------------------- + + /** Quarter note: only subdivision step 1 active. */ + public static final byte BEAT_PATTERN_QUARTER = (byte) 0x80; + /** Both eighth notes active. */ + public static final byte BEAT_PATTERN_EIGHTH = (byte) 0xC0; + /** All three triplet subdivisions active. */ + public static final byte BEAT_PATTERN_TRIPLET = (byte) 0xE0; + /** All four sixteenth-note subdivisions active. */ + public static final byte BEAT_PATTERN_SIXTEENTH = (byte) 0xF0; + + // ------------------------------------------------------------------------- + // Shared-preference keys + // ------------------------------------------------------------------------- + + public static final String PREF_SOUNDBRENNER_PREFIX = "soundbrenner_"; + public static final String PREF_BEAT_ACCENT_PREFIX = "soundbrenner_beat_accent_"; + + public static final String PREF_BPM = "soundbrenner_bpm"; + public static final String PREF_TIME_SIGNATURE = "soundbrenner_time_signature"; + public static final String PREF_SUBDIVISION = "soundbrenner_subdivision"; + + /** Stores the last known running/stopped state so the UI can restore it. */ + public static final String PREF_METRONOME_RUNNING = "soundbrenner_metronome_running"; + + // Individual beat accent strengths (UI values 0-3); beats 1-8. + public static final String PREF_BEAT_1 = "soundbrenner_beat_accent_1"; + public static final String PREF_BEAT_2 = "soundbrenner_beat_accent_2"; + public static final String PREF_BEAT_3 = "soundbrenner_beat_accent_3"; + public static final String PREF_BEAT_4 = "soundbrenner_beat_accent_4"; + public static final String PREF_BEAT_5 = "soundbrenner_beat_accent_5"; + public static final String PREF_BEAT_6 = "soundbrenner_beat_accent_6"; + public static final String PREF_BEAT_7 = "soundbrenner_beat_accent_7"; + public static final String PREF_BEAT_8 = "soundbrenner_beat_accent_8"; + public static final String PREF_BEAT_9 = "soundbrenner_beat_accent_9"; + public static final String PREF_BEAT_10 = "soundbrenner_beat_accent_10"; + public static final String PREF_BEAT_11 = "soundbrenner_beat_accent_11"; + public static final String PREF_BEAT_12 = "soundbrenner_beat_accent_12"; + public static final String PREF_BEAT_13 = "soundbrenner_beat_accent_13"; + public static final String PREF_BEAT_14 = "soundbrenner_beat_accent_14"; + public static final String PREF_BEAT_15 = "soundbrenner_beat_accent_15"; + public static final String PREF_BEAT_16 = "soundbrenner_beat_accent_16"; + + /** Ordered list of all per-beat accent pref keys; index 0 = beat 1. */ + public static final String[] PREF_BEATS = { + PREF_BEAT_1, PREF_BEAT_2, PREF_BEAT_3, PREF_BEAT_4, + PREF_BEAT_5, PREF_BEAT_6, PREF_BEAT_7, PREF_BEAT_8, + PREF_BEAT_9, PREF_BEAT_10, PREF_BEAT_11, PREF_BEAT_12, + PREF_BEAT_13, PREF_BEAT_14, PREF_BEAT_15, PREF_BEAT_16 + }; + + // ------------------------------------------------------------------------- + // Default values + // ------------------------------------------------------------------------- + + public static final int DEFAULT_BPM = 120; + public static final String DEFAULT_TIME_SIG = "4/4"; + public static final int DEFAULT_SUBDIVISION = 1; // 1=quarter, 2=eighth, 3=triplet, 4=sixteenth + public static final int DEFAULT_BEAT_ACCENT_1 = 3; // accent (loudest) on beat 1 + public static final int DEFAULT_BEAT_ACCENT_N = 1; // soft on all other beats +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundbrenner/SoundbrennerCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundbrenner/SoundbrennerCoordinator.java new file mode 100644 index 0000000000..37ed6fce44 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/soundbrenner/SoundbrennerCoordinator.java @@ -0,0 +1,127 @@ +package nodomain.freeyourgadget.gadgetbridge.devices.soundbrenner; + +import androidx.annotation.NonNull; + +import android.content.Context; + +import java.util.regex.Pattern; +import java.util.Arrays; +import java.util.List; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer; +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.soundbrenner.SoundbrennerSupport; + + +public class SoundbrennerCoordinator extends AbstractBLEDeviceCoordinator { + + @Override + public String getManufacturer() { + return "Soundbrenner"; + } + + @Override + protected Pattern getSupportedDeviceName() { + return Pattern.compile("Soundbrenner Core.*"); + } + + @NonNull + @Override + public Class getDeviceSupportClass(final GBDevice device) { + return SoundbrennerSupport.class; + } + + @Override + public int getDeviceNameResource() { + return R.string.devicetype_soundbrenner_core; + } + + @Override + public int getDefaultIconResource() { + return R.drawable.ic_notification; + } + + @NonNull + @Override + public DeviceCoordinator.DeviceKind getDeviceKind(@NonNull GBDevice device) { + return DeviceCoordinator.DeviceKind.WATCH; + } + + // ------------------------------------------------------------------------- + // Device-specific settings + // ------------------------------------------------------------------------- + + @Override + public int[] getSupportedDeviceSpecificSettings(GBDevice device) { + return new int[]{ + R.xml.devicesettings_soundbrenner_core + }; + } + + @NonNull + @Override + public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(@NonNull final GBDevice device) { + return new SoundbrennerSettingsCustomizer(); + } + // ------------------------------------------------------------------------- + // Start/Stop buttons in gadget card + // ------------------------------------------------------------------------- + + @Override + public List getCustomActions() { + return Arrays.asList( + + // START + new DeviceCardAction() { + @Override + public int getIcon(@NonNull final GBDevice device) { + return R.drawable.ic_play; + } + + @NonNull + @Override + public String getDescription(@NonNull final GBDevice device, + @NonNull final Context context) { + return context.getString(R.string.soundbrenner_action_start); + } + + @Override + public void onClick(@NonNull final GBDevice device, + @NonNull final Context context) { + GBApplication.deviceService(device) + .onSendConfiguration( + SoundbrennerConstants.PREF_METRONOME_RUNNING + "_start"); + } + }, + + // STOP + new DeviceCardAction() { + @Override + public int getIcon(@NonNull final GBDevice device) { + return R.drawable.ic_stop; + } + + @NonNull + @Override + public String getDescription(@NonNull final GBDevice device, + @NonNull final Context context) { + return context.getString(R.string.soundbrenner_action_stop); + } + + @Override + public void onClick(@NonNull final GBDevice device, + @NonNull final Context context) { + GBApplication.deviceService(device) + .onSendConfiguration( + SoundbrennerConstants.PREF_METRONOME_RUNNING + "_stop"); + } + } + ); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java index 12c166b80a..713c13dec4 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java @@ -440,6 +440,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWISP600NCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.sony.wena3.SonyWena3Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.sonyswr12.SonySWR12DeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.soundbrenner.SoundbrennerCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.soundcore.aerofit.SoundcoreAeroFit2Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.soundcore.liberty3_pro.SoundcoreLiberty3ProCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.soundcore.liberty4_nc.SoundcoreLiberty4NCCoordinator; @@ -838,6 +839,7 @@ public enum DeviceType { VICTRON_SMARTSHUNT(VictronSmartShuntCoordinator.class), SINILINK(SinilinkCoordinator.class), ONETOUCH(OneTouchCoordinator.class), + SOUNDBRENNER_CORE(SoundbrennerCoordinator.class), SOUNDCORE_LIBERTY3_PRO(SoundcoreLiberty3ProCoordinator.class), SOUNDCORE_LIBERTY4_NC(SoundcoreLiberty4NCCoordinator.class), SOUNDCORE_MOTION300(SoundcoreMotion300Coordinator.class), diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundbrenner/SoundbrennerSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundbrenner/SoundbrennerSettingsCustomizer.java new file mode 100644 index 0000000000..9adb8ccb8c --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundbrenner/SoundbrennerSettingsCustomizer.java @@ -0,0 +1,150 @@ +package nodomain.freeyourgadget.gadgetbridge.devices.soundbrenner; + +import android.os.Parcel; + +import androidx.annotation.NonNull; +import androidx.preference.ListPreference; +import androidx.preference.Preference; + +import java.util.Collections; +import java.util.Set; + +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer; +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler; +import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper; +import nodomain.freeyourgadget.gadgetbridge.util.GB; +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.util.Prefs; + +/** + * Settings customizer for the Soundbrenner Core. + * + * Shows/hides per-beat accent SeekBars depending on the selected time-signature + * numerator. Only beats 1..numerator are visible; the rest are hidden. + * + * All preference keys are referenced via {@link SoundbrennerConstants}. + */ +public class SoundbrennerSettingsCustomizer implements DeviceSpecificSettingsCustomizer { + + // ------------------------------------------------------------------------- + // Parcelable boilerplate + // ------------------------------------------------------------------------- + + public static final Creator CREATOR = + new Creator() { + @Override + public SoundbrennerSettingsCustomizer createFromParcel(final Parcel in) { + return new SoundbrennerSettingsCustomizer(in); + } + + @Override + public SoundbrennerSettingsCustomizer[] newArray(final int size) { + return new SoundbrennerSettingsCustomizer[size]; + } + }; + + public SoundbrennerSettingsCustomizer() {} + + protected SoundbrennerSettingsCustomizer(final Parcel in) {} + + @Override + public int describeContents() { return 0; } + + @Override + public void writeToParcel(@NonNull final Parcel dest, final int flags) {} + + // ------------------------------------------------------------------------- + // DeviceSpecificSettingsCustomizer + // ------------------------------------------------------------------------- + + /** + * Called after any preference in this screen changes and has been persisted. + * Re-evaluates beat-bar visibility when the time signature is updated. + * + * The new value is read directly from the ListPreference object because + * DeviceSpecificSettingsHandler does not expose a getPrefs() accessor. + */ + @Override + public void onPreferenceChange(final Preference preference, + final DeviceSpecificSettingsHandler handler) { + if (SoundbrennerConstants.PREF_TIME_SIGNATURE.equals(preference.getKey()) + && preference instanceof ListPreference) { + final String timeSig = ((ListPreference) preference).getValue(); + updateBeatAccentVisibility(handler, parseNumerator(timeSig)); + } + + // Trigger onSendConfiguration() in SoundbrennerSupport so the BLE + // config packet is written to the device whenever any preference changes. + handler.notifyPreferenceChanged(preference.getKey()); + } + + /** + * Called once when the settings screen is first built. + * Registers the change handler and sets initial beat-bar visibility from + * the {@code prefs} snapshot that Gadgetbridge passes in. + */ + @Override + public void customizeSettings(final DeviceSpecificSettingsHandler handler, + final Prefs prefs, + final String rootKey) { + // Register so onPreferenceChange fires for ALL Soundbrenner preferences. + handler.addPreferenceHandlerFor(SoundbrennerConstants.PREF_TIME_SIGNATURE); + handler.addPreferenceHandlerFor(SoundbrennerConstants.PREF_BPM); + handler.addPreferenceHandlerFor(SoundbrennerConstants.PREF_SUBDIVISION); + for (final String beatKey : SoundbrennerConstants.PREF_BEATS) { + handler.addPreferenceHandlerFor(beatKey); + } + + // Apply initial visibility from the persisted time-signature value. + final String timeSig = prefs.getString( + SoundbrennerConstants.PREF_TIME_SIGNATURE, + SoundbrennerConstants.DEFAULT_TIME_SIG); + updateBeatAccentVisibility(handler, parseNumerator(timeSig)); + + // Connect Start Stop Button + Preference togglePref = handler.findPreference("soundbrenner_metronome_toggle"); + if (togglePref != null) { + togglePref.setOnPreferenceClickListener(preference -> { + // Kommando via GBDeviceEventConfigurationUpdate an den Support schicken + GBApplication.deviceService(handler.getDevice()) + .onSendConfiguration(SoundbrennerConstants.PREF_METRONOME_RUNNING + "_toggle"); + return true; + }); + } + } + + @Override + public Set getPreferenceKeysWithSummary() { + return Collections.emptySet(); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * Show SeekBars for beats 1..numerator, hide beats (numerator+1)..8. + */ + private void updateBeatAccentVisibility(final DeviceSpecificSettingsHandler handler, + final int numerator) { + for (int i = 0; i < SoundbrennerConstants.PREF_BEATS.length; i++) { + final Preference pref = handler.findPreference(SoundbrennerConstants.PREF_BEATS[i]); + if (pref != null) { + pref.setVisible(i < numerator); + } + } + } + + /** + * Parse the numerator from a string like "4/4" or "3/8". + * Returns 4 on any parse error. + */ + private int parseNumerator(final String timeSig) { + try { + if (timeSig != null && timeSig.contains("/")) { + return Integer.parseInt(timeSig.split("/")[0]); + } + } catch (final NumberFormatException ignored) {} + return 4; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundbrenner/SoundbrennerSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundbrenner/SoundbrennerSupport.java new file mode 100644 index 0000000000..5a64ef0fa0 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/soundbrenner/SoundbrennerSupport.java @@ -0,0 +1,515 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.soundbrenner; + +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCharacteristic; +import android.content.Intent; +import android.content.SharedPreferences; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.devices.soundbrenner.SoundbrennerConstants; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.BatteryState; +import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLESingleDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.btle.GattService; +import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.IntentListener; +import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.battery.BatteryInfo; +import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.battery.BatteryInfoProfile; + +public class SoundbrennerSupport extends AbstractBTLESingleDeviceSupport { + + private static final Logger LOG = LoggerFactory.getLogger(SoundbrennerSupport.class); + + /** Mirrors the last command sent so the UI and onCharacteristicChanged stay in sync. */ + private boolean metronomeRunning = false; + + private final GBDeviceEventBatteryInfo batteryCmd = new GBDeviceEventBatteryInfo(); + private final BatteryInfoProfile batteryInfoProfile; + + public SoundbrennerSupport() { + super(LOG); + addSupportedService(SoundbrennerConstants.UUID_SERVICE_METRONOME); + addSupportedService(GattService.UUID_SERVICE_BATTERY_SERVICE); + + batteryInfoProfile = new BatteryInfoProfile<>(this); + batteryInfoProfile.addListener(new IntentListener() { + @Override + public void notify(Intent intent) { + if (BatteryInfoProfile.ACTION_BATTERY_INFO.equals(intent.getAction())) { + handleBatteryInfo((BatteryInfo) + intent.getParcelableExtra(BatteryInfoProfile.EXTRA_BATTERY_INFO)); + } + } + }); + addSupportedProfile(batteryInfoProfile); + } + + // ------------------------------------------------------------------------- + // AbstractBTLESingleDeviceSupport + // ------------------------------------------------------------------------- + + @Override + protected TransactionBuilder initializeDevice(TransactionBuilder builder) { + LOG.info("Initializing Soundbrenner Core"); + + // Request maximum MTU first – the 32-byte config packet exceeds the default 20-byte limit. + builder.requestMtu(512); + + // Battery Service: handled via BatteryInfoProfile (standard GATT Battery Service) + batteryInfoProfile.requestBatteryInfo(builder); + batteryInfoProfile.enableNotify(builder, true); + + builder.setDeviceState(GBDevice.State.INITIALIZING); + + // Enable NOTIFY on CHAR_CONFIG to receive watch-side parameter changes. + BluetoothGattCharacteristic configChar = + getCharacteristic(SoundbrennerConstants.UUID_CHARACTERISTIC_CONFIG); + if (configChar != null) { + builder.notify(configChar, true); + } else { + LOG.warn("CHAR_CONFIG not found during init – notifications disabled"); + } + + // Enable INDICATE on CHAR_CONTROL to track start/stop confirmations. + BluetoothGattCharacteristic controlChar = + getCharacteristic(SoundbrennerConstants.UUID_CHARACTERISTIC_CONTROL); + if (controlChar != null) { + builder.notify(controlChar, true); + } else { + LOG.warn("CHAR_CONTROL not found during init – indications disabled"); + } + + // Restore previously persisted metronome state flag. + SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs( + getDevice().getAddress()); + metronomeRunning = prefs.getBoolean(SoundbrennerConstants.PREF_METRONOME_RUNNING, false); + + builder.setDeviceState(GBDevice.State.INITIALIZED); + return builder; + } + + @Override + public boolean useAutoConnect() { + return true; + } + + // ------------------------------------------------------------------------- + // Metronome control + // ------------------------------------------------------------------------- + + /** + * Toggle the metronome on/off. If turning on, the current configuration + * is written first so the device is always in a consistent state. + */ + public void toggleMetronome() { + try { + if (metronomeRunning) { + stopMetronome(); + } else { + startMetronome(); + } + } catch (Exception e) { + LOG.error("Error toggling metronome", e); + } + } + + /** Write config then send start command. */ + public void startMetronome() throws IOException { + TransactionBuilder builder = performInitialized("StartMetronome"); + writeConfigPacket(builder); + sendPreStartCommand(builder); // 0xd1 to 0x0063 before start + writeConfigPacket(builder); // seconds Config Write as in snoop log + sendControlCommand(builder, SoundbrennerConstants.CMD_METRONOME_START); + builder.queue(); + persistMetronomeRunning(true); + metronomeRunning = true; + LOG.info("Metronome start command queued"); + } + + /** Send stop command only (no config write needed). */ + public void stopMetronome() throws IOException { + TransactionBuilder builder = performInitialized("StopMetronome"); + sendControlCommand(builder, SoundbrennerConstants.CMD_METRONOME_STOP); + builder.queue(); + persistMetronomeRunning(false); + metronomeRunning = false; + LOG.info("Metronome stop command queued"); + } + + // ------------------------------------------------------------------------- + // Configuration + // ------------------------------------------------------------------------- + + /** + * Called by Gadgetbridge whenever a device-specific preference changes. + * Checks the key against known prefix constants – no hard-coded strings. + */ + @Override + public void onSendConfiguration(String key) { + // UI button toggle + if ((SoundbrennerConstants.PREF_METRONOME_RUNNING + "_toggle").equals(key)) { + toggleMetronome(); + return; + } + // Device-card Start/Stop buttons + if ((SoundbrennerConstants.PREF_METRONOME_RUNNING + "_start").equals(key)) { + try { + startMetronome(); + } catch (IOException e) { + LOG.error("Failed to start metronome", e); + } + return; + } + if ((SoundbrennerConstants.PREF_METRONOME_RUNNING + "_stop").equals(key)) { + try { + stopMetronome(); + } catch (IOException e) { + LOG.error("Failed to stop metronome", e); + } + return; + } + + boolean isSoundbrennerPref = key.startsWith(SoundbrennerConstants.PREF_SOUNDBRENNER_PREFIX) + || key.startsWith(SoundbrennerConstants.PREF_BEAT_ACCENT_PREFIX); + + if (!isSoundbrennerPref) { + return; + } + + LOG.info("Soundbrenner pref changed: {} – rebuilding config packet", key); + try { + TransactionBuilder builder = performInitialized("SendConfig"); + writeConfigPacket(builder); + builder.queue(); + } catch (IOException e) { + LOG.error("Failed to send configuration packet", e); + } + } + + // ------------------------------------------------------------------------- + // GATT callbacks + // ------------------------------------------------------------------------- + + @Override + public boolean onCharacteristicChanged(BluetoothGatt gatt, + BluetoothGattCharacteristic characteristic, + byte[] value) { + if (SoundbrennerConstants.UUID_CHARACTERISTIC_CONTROL.equals( + characteristic.getUuid())) { + handleControlNotify(value); + return true; + } + + if (SoundbrennerConstants.UUID_CHARACTERISTIC_CONFIG.equals( + characteristic.getUuid())) { + handleConfigNotify(value); + return true; + } + + return false; + } + + @Override + public boolean onCharacteristicRead(BluetoothGatt gatt, + BluetoothGattCharacteristic characteristic, + byte[] value, + int status) { + return super.onCharacteristicRead(gatt, characteristic, value, status); + } + + // ------------------------------------------------------------------------- + // Private – packet builder + // ------------------------------------------------------------------------- + + /** + * Build and write the full 32-byte configuration packet from the current + * device-specific SharedPreferences. + * + * All SeekBarPreference values are stored as {@code int} via {@code putInt}. + */ + private void writeConfigPacket(TransactionBuilder builder) { + SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs( + getDevice().getAddress()); + + // --- Read preferences --- + // PREF_BPM is stored by SeekBarPreference as int. + int bpm = prefs.getInt(SoundbrennerConstants.PREF_BPM, + SoundbrennerConstants.DEFAULT_BPM); + + // PREF_TIME_SIGNATURE is a ListPreference → stored as String. + String timeSig = prefs.getString(SoundbrennerConstants.PREF_TIME_SIGNATURE, + SoundbrennerConstants.DEFAULT_TIME_SIG); + + // PREF_SUBDIVISION is a ListPreference → stored as String, never as int. + // Using getInt() here causes a ClassCastException → crash. + int subdivision; + try { + subdivision = Integer.parseInt( + prefs.getString(SoundbrennerConstants.PREF_SUBDIVISION, + String.valueOf(SoundbrennerConstants.DEFAULT_SUBDIVISION))); + } catch (NumberFormatException e) { + LOG.error("Failed to parse subdivision pref, using default"); + subdivision = SoundbrennerConstants.DEFAULT_SUBDIVISION; + } + + // Parse time signature (e.g. "4/4" -> numerator=4, denominator=4) + int numerator = 4; + int denominator = 4; + try { + String[] parts = timeSig.split("/"); + numerator = Integer.parseInt(parts[0]); + denominator = Integer.parseInt(parts[1]); + } catch (Exception e) { + LOG.error("Failed to parse time signature '{}', using 4/4", timeSig); + } + + // Read per-beat accent strengths (beats 1..8; unused beats default to soft) + int[] beatStrengths = new int[8]; + for (int i = 0; i < 8; i++) { + int defaultStrength = (i == 0) + ? SoundbrennerConstants.DEFAULT_BEAT_ACCENT_1 + : SoundbrennerConstants.DEFAULT_BEAT_ACCENT_N; + beatStrengths[i] = prefs.getInt(SoundbrennerConstants.PREF_BEATS[i], defaultStrength); + } + + // --- Build packet (structure mirrors the validated buildPacket reference) --- + byte[] packet = new byte[SoundbrennerConstants.PACKET_SIZE]; + + // Bytes 0-3: BPM as IEEE 754 float, big-endian + int bpmBits = Float.floatToIntBits((float) bpm); + packet[0] = (byte) ((bpmBits >> 24) & 0xFF); + packet[1] = (byte) ((bpmBits >> 16) & 0xFF); + packet[2] = (byte) ((bpmBits >> 8) & 0xFF); + packet[3] = (byte) ( bpmBits & 0xFF); + + // Bytes 4: time signature + // High nibble = (numerator-1), low nibble = log2(denominator) + int log2denom = (int) (Math.log(denominator) / Math.log(2)); + byte timeSigByte = (byte) (((numerator - 1) & 0xF) << 4 | (log2denom & 0xF)); + packet[4] = timeSigByte; + + // Byte 5: beat strengths beats 1-4, 2 bits per beat MSB-first + // Byte 6: beat strengths beats 5-8; unused slots = 0x00 (wire default) + // Byte 7: unused, fixed 0x00 + int strengthHi = 0; + int strengthLo = 0x00; + for (int i = 0; i < Math.min(numerator, 4); i++) { + strengthHi |= (strengthToDeviceBits(beatStrengths[i]) << (6 - i * 2)); + } + for (int i = 4; i < Math.min(numerator, 8); i++) { + int slot = i - 4; + strengthLo &= ~(0x3 << (6 - slot * 2)); + strengthLo |= (strengthToDeviceBits(beatStrengths[i]) << (6 - slot * 2)); + } + packet[5] = (byte) strengthHi; + packet[6] = (byte) strengthLo; + packet[7] = (byte) 0x00; + + // Byte 8: fixed 0x00 + packet[8] = (byte) 0x00; + + // Bytes 9-10: subdivision constant, 16-bit big-endian + // Formula: (steps - 1) * (9352 + 2 * numerator) + int subdivConst = (subdivision - 1) * (9352 + 2 * numerator); + packet[9] = (byte) ((subdivConst >> 8) & 0xFF); + packet[10] = (byte) ( subdivConst & 0xFF); + + // Bytes 11-14: fixed 0x00 + packet[11] = (byte) 0x00; + packet[12] = (byte) 0x00; + packet[13] = (byte) 0x00; + packet[14] = (byte) 0x00; + + // Bytes 15..(15+numerator-1): one pattern byte per active beat + // subdivisionPatternByte() returns the bitmask shifted to bits 7-4 + byte beatPatternByte = subdivisionPatternByte(subdivision); + for (int i = 0; i < numerator; i++) { + packet[15 + i] = beatPatternByte; + } + + // Bytes (15+numerator)..30: inactive beat slots = 0x00 + for (int i = 15 + numerator; i <= 30; i++) { + packet[i] = (byte) 0x00; + } + + // Byte 31: footer + packet[31] = (byte) 0x00; + + // --- Write --- + BluetoothGattCharacteristic configChar = + getCharacteristic(SoundbrennerConstants.UUID_CHARACTERISTIC_CONFIG); + if (configChar != null) { + builder.write(configChar, packet); + LOG.debug("Config packet queued: bpm={}, timeSig={}, subdiv={}", bpm, timeSig, subdivision); + } else { + LOG.error("CHAR_CONFIG not found – cannot write configuration"); + } + } + + + /** + * Map UI accent strength (0-3) to the 2-bit inverted wire value. + * UI to wire: 0 to 3 (silent), 1 to 0 (soft), 2 to 1 (medium), 3 to 2 (strong) + */ + private int strengthToDeviceBits(int uiStrength) { + return (uiStrength == 0) ? 3 : (uiStrength - 1); + } + + /** + * Return the beat-pattern byte for a given subdivision step count. + * Bits 7-4 = active subdivision steps; bits 3-0 = always 0. + */ + private byte subdivisionPatternByte(int steps) { + switch (steps) { + case 2: return SoundbrennerConstants.BEAT_PATTERN_EIGHTH; + case 3: return SoundbrennerConstants.BEAT_PATTERN_TRIPLET; + case 4: return SoundbrennerConstants.BEAT_PATTERN_SIXTEENTH; + default: return SoundbrennerConstants.BEAT_PATTERN_QUARTER; // 1 = quarter + } + } + + /** Write a single-byte command to CHAR_CONTROL. */ + private void sendControlCommand(TransactionBuilder builder, byte command) { + BluetoothGattCharacteristic controlChar = + getCharacteristic(SoundbrennerConstants.UUID_CHARACTERISTIC_CONTROL); + if (controlChar != null) { + builder.write(controlChar, new byte[]{command}); + } else { + LOG.error("CHAR_CONTROL not found – cannot send command 0x{:02X}", command & 0xFF); + } + } + + /** + * Write the magic pre-start byte (0xd1) to the unknown control characteristic + * (handle 0x0063, UUID 061a5b49). The official app always sends this before + * writing the final config and issuing CMD_METRONOME_START. + */ + private void sendPreStartCommand(TransactionBuilder builder) { + BluetoothGattCharacteristic unknownCtrl = + getCharacteristic(SoundbrennerConstants.UUID_CHARACTERISTIC_UNKNOWN_CTRL); + if (unknownCtrl != null) { + builder.write(unknownCtrl, SoundbrennerConstants.CMD_PRE_START); + LOG.debug("Pre-start byte (0xd1) queued on 0x0063"); + } else { + LOG.warn("UUID_CHARACTERISTIC_UNKNOWN_CTRL not found – skipping pre-start"); + } + } + + // ------------------------------------------------------------------------- + // Private – notify handlers + // ------------------------------------------------------------------------- + + /** + * Handle an INDICATE from CHAR_CONTROL (device confirms start/stop). + */ + private void handleControlNotify(byte[] value) { + if (value == null || value.length == 0) return; + if (value[0] == SoundbrennerConstants.CMD_METRONOME_START) { + metronomeRunning = true; + persistMetronomeRunning(true); + LOG.info("Device confirmed: metronome started"); + } else if (value[0] == SoundbrennerConstants.CMD_METRONOME_STOP) { + metronomeRunning = false; + persistMetronomeRunning(false); + LOG.info("Device confirmed: metronome stopped"); + } + } + + private void handleBatteryInfo(BatteryInfo info) { + if (info == null) return; + batteryCmd.level = (short) info.getPercentCharged(); + handleGBDeviceEvent(batteryCmd); + LOG.info("Battery level: {}%", batteryCmd.level); + } + /** + * Handle a NOTIFY from CHAR_CONFIG (user changed settings on the watch). + * Parses the 32-byte packet and writes the new values back to SharedPreferences + * so the Gadgetbridge UI stays in sync. + */ + private void handleConfigNotify(byte[] value) { + if (value == null || value.length < SoundbrennerConstants.PACKET_SIZE) { + LOG.warn("Received short/null config notify payload ({} bytes)", + value == null ? 0 : value.length); + return; + } + + ByteBuffer buf = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN); + + // Bytes 0-3: BPM + float bpm = buf.getFloat(); + + // Byte 4: time signature + buf.position(4); + int timeSigByte = buf.get() & 0xFF; + int numerator = (timeSigByte >> 4) + 1; + int log2denom = timeSigByte & 0x0F; + int denominator = 1 << log2denom; + String timeSig = numerator + "/" + denominator; + + // Byte 5: beat strengths 1-4 (position is already 5 after the get() above) + int rawStrengths1_4 = buf.get() & 0xFF; + + // Byte 6: beat strengths 5-8 + int rawStrengths5_8 = buf.get() & 0xFF; + + // Bytes 9-10: subdivision constant -> steps + buf.position(9); + int subdivConst = buf.getShort() & 0xFFFF; + int base = 9352 + 2 * numerator; + int subdivision = (base > 0) ? (subdivConst / base) + 1 : 1; + if (subdivision < 1 || subdivision > 4) subdivision = 1; + + // Decode strengths + int[] beatStrengths = new int[8]; + for (int slot = 0; slot < 4; slot++) { + int wire = (rawStrengths1_4 >> (6 - slot * 2)) & 0x03; + beatStrengths[slot] = wireToUiStrength(wire); + } + for (int slot = 0; slot < 4; slot++) { + int wire = (rawStrengths5_8 >> (6 - slot * 2)) & 0x03; + beatStrengths[4 + slot] = wireToUiStrength(wire); + } + + // Persist back to SharedPreferences + SharedPreferences.Editor editor = GBApplication + .getDeviceSpecificSharedPrefs(getDevice().getAddress()) + .edit(); + editor.putInt(SoundbrennerConstants.PREF_BPM, (int) bpm); + editor.putString(SoundbrennerConstants.PREF_TIME_SIGNATURE, timeSig); + // PREF_SUBDIVISION is a ListPreference → must be stored as String. + editor.putString(SoundbrennerConstants.PREF_SUBDIVISION, String.valueOf(subdivision)); + for (int i = 0; i < 8; i++) { + editor.putInt(SoundbrennerConstants.PREF_BEATS[i], beatStrengths[i]); + } + editor.apply(); + + LOG.info("Config notify from watch parsed: bpm={}, timeSig={}, subdiv={}", bpm, timeSig, subdivision); + } + + /** Invert the wire encoding back to a UI strength value. */ + private int wireToUiStrength(int wire) { + switch (wire & 0x03) { + case 0b11: return 0; + case 0b00: return 1; + case 0b01: return 2; + case 0b10: return 3; + default: return 2; + } + } + + /** Persist the running flag so it survives reconnects. */ + private void persistMetronomeRunning(boolean running) { + GBApplication.getDeviceSpecificSharedPrefs(getDevice().getAddress()) + .edit() + .putBoolean(SoundbrennerConstants.PREF_METRONOME_RUNNING, running) + .apply(); + } +} diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 499d8ec531..ccac089e9b 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -4563,6 +4563,50 @@ anc_mode__anc_off anc_mode__transparency_off + + + 4/4 + 2/4 + 3/4 + 5/4 + 6/4 + 3/8 + 4/8 + 5/8 + 6/8 + 9/8 + 12/8 + 2/2 + + + + 4/4 + 2/4 + 3/4 + 5/4 + 6/4 + 3/8 + 4/8 + 5/8 + 6/8 + 9/8 + 12/8 + 2/2 + + + + Quarter notes + Eighth notes + Triplets + Sixteenth notes + + + + 1 + 2 + 3 + 4 + @string/prefs_active_noise_cancelling_transport diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8f0eb31ffd..4deace22ec 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2155,6 +2155,7 @@ Sony WI-SP600N Sony LinkBuds Sony LinkBuds S + Soundbrenner Core Soundcore Liberty 3 Pro Soundcore Liberty 4 NC Soundcore Motion 300 @@ -3536,6 +3537,35 @@ Switch Active Noise Cancelling Mode Long Press (1s) Long Press (3s) + Control + Start/Stop + Start or stop metronome + Start + Stop + Tempo + Time Signature + Subdivision + Beat Accents + BPM + Beats per minute (20–400) + Time Signature + Subdivision + Beat 1 Accent + Beat 2 Accent + Beat 3 Accent + Beat 4 Accent + Beat 5 Accent + Beat 6 Accent + Beat 7 Accent + Beat 8 Accent + Beat 9 Accent + Beat 10 Accent + Beat 11 Accent + Beat 12 Accent + Beat 13 Accent + Beat 14 Accent + Beat 15 Accent + Beat 16 Accent Voice Prompts Button Brightness Low diff --git a/app/src/main/res/xml/devicesettings_soundbrenner_core.xml b/app/src/main/res/xml/devicesettings_soundbrenner_core.xml new file mode 100644 index 0000000000..0e4e8617d8 --- /dev/null +++ b/app/src/main/res/xml/devicesettings_soundbrenner_core.xml @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +