initial support

This commit is contained in:
Mathias Micheel
2026-06-19 23:33:45 +02:00
committed by José Rebelo
parent 69180dc3a4
commit 5fb412c296
8 changed files with 1213 additions and 0 deletions
@@ -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
}
@@ -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<? extends DeviceSupport> 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<DeviceCardAction> 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");
}
}
);
}
}
@@ -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),
@@ -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<SoundbrennerSettingsCustomizer> CREATOR =
new Creator<SoundbrennerSettingsCustomizer>() {
@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<String> 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;
}
}
@@ -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<SoundbrennerSupport> 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();
}
}
+44
View File
@@ -4563,6 +4563,50 @@
<item>anc_mode__anc_off</item>
<item>anc_mode__transparency_off</item>
</string-array>
<string-array name="soundbrenner_time_sig_entries">
<item>4/4</item>
<item>2/4</item>
<item>3/4</item>
<item>5/4</item>
<item>6/4</item>
<item>3/8</item>
<item>4/8</item>
<item>5/8</item>
<item>6/8</item>
<item>9/8</item>
<item>12/8</item>
<item>2/2</item>
</string-array>
<string-array name="soundbrenner_time_sig_values">
<item>4/4</item>
<item>2/4</item>
<item>3/4</item>
<item>5/4</item>
<item>6/4</item>
<item>3/8</item>
<item>4/8</item>
<item>5/8</item>
<item>6/8</item>
<item>9/8</item>
<item>12/8</item>
<item>2/2</item>
</string-array>
<string-array name="soundbrenner_subdivision_entries">
<item>Quarter notes</item>
<item>Eighth notes</item>
<item>Triplets</item>
<item>Sixteenth notes</item>
</string-array>
<string-array name="soundbrenner_subdivision_values">
<item>1</item>
<item>2</item>
<item>3</item>
<item>4</item>
</string-array>
<string-array name="soundcore_anc_mode_names">
<item>@string/prefs_active_noise_cancelling_transport</item>
+30
View File
@@ -2155,6 +2155,7 @@
<string name="devicetype_sony_wi_sp600n" translatable="false">Sony WI-SP600N</string>
<string name="devicetype_sony_linkbuds" translatable="false">Sony LinkBuds</string>
<string name="devicetype_sony_linkbuds_s" translatable="false">Sony LinkBuds S</string>
<string name="devicetype_soundbrenner_core" translatable="false">Soundbrenner Core</string>
<string name="devicetype_soundcore_liberty3_pro" translatable="false">Soundcore Liberty 3 Pro</string>
<string name="devicetype_soundcore_liberty4_nc" translatable="false">Soundcore Liberty 4 NC</string>
<string name="devicetype_soundcore_motion300" translatable="false">Soundcore Motion 300</string>
@@ -3536,6 +3537,35 @@
<string name="moondrop_touch_action_anc_mode">Switch Active Noise Cancelling Mode</string>
<string name="moondrop_touch_trigger_long_press_1s">Long Press (1s)</string>
<string name="moondrop_touch_trigger_long_press_3s">Long Press (3s)</string>
<string name="soundbrenner_category_control">Control</string>
<string name="soundbrenner_pref_metronome_toggle_title">Start/Stop</string>
<string name="soundbrenner_pref_metronome_toggle_summary">Start or stop metronome</string>
<string name="soundbrenner_action_start">Start</string>
<string name="soundbrenner_action_stop">Stop</string>
<string name="soundbrenner_category_tempo">Tempo</string>
<string name="soundbrenner_category_time_signature">Time Signature</string>
<string name="soundbrenner_category_subdivision">Subdivision</string>
<string name="soundbrenner_category_accents">Beat Accents</string>
<string name="soundbrenner_pref_bpm_title">BPM</string>
<string name="soundbrenner_pref_bpm_summary">Beats per minute (20400)</string>
<string name="soundbrenner_pref_time_sig_title">Time Signature</string>
<string name="soundbrenner_pref_subdivision_title">Subdivision</string>
<string name="soundbrenner_pref_beat_1_title">Beat 1 Accent</string>
<string name="soundbrenner_pref_beat_2_title">Beat 2 Accent</string>
<string name="soundbrenner_pref_beat_3_title">Beat 3 Accent</string>
<string name="soundbrenner_pref_beat_4_title">Beat 4 Accent</string>
<string name="soundbrenner_pref_beat_5_title">Beat 5 Accent</string>
<string name="soundbrenner_pref_beat_6_title">Beat 6 Accent</string>
<string name="soundbrenner_pref_beat_7_title">Beat 7 Accent</string>
<string name="soundbrenner_pref_beat_8_title">Beat 8 Accent</string>
<string name="soundbrenner_pref_beat_9_title">Beat 9 Accent</string>
<string name="soundbrenner_pref_beat_10_title">Beat 10 Accent</string>
<string name="soundbrenner_pref_beat_11_title">Beat 11 Accent</string>
<string name="soundbrenner_pref_beat_12_title">Beat 12 Accent</string>
<string name="soundbrenner_pref_beat_13_title">Beat 13 Accent</string>
<string name="soundbrenner_pref_beat_14_title">Beat 14 Accent</string>
<string name="soundbrenner_pref_beat_15_title">Beat 15 Accent</string>
<string name="soundbrenner_pref_beat_16_title">Beat 16 Accent</string>
<string name="soundcore_voice_prompts">Voice Prompts</string>
<string name="soundcore_button_brightness">Button Brightness</string>
<string name="soundcore_button_brightness_low">Low</string>
@@ -0,0 +1,156 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Device-specific settings screen for the Soundbrenner Core.
Registered in SoundbrennerCoordinator.getSupportedDeviceSpecificSettings().
Key names are defined in SoundbrennerConstants no hard-coded strings here.
Note on preference storage:
SeekBarPreference (BPM, beat accents) calls putInt internally → read with getInt().
ListPreference (time signature, subdivision) stores String → read with getString().
-->
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<!-- ===================================================================
Control buttons (Start/Stop)
=================================================================== -->
<PreferenceCategory android:title="@string/soundbrenner_category_control">
<Preference
android:key="soundbrenner_metronome_toggle"
android:title="@string/soundbrenner_pref_metronome_toggle_title"
android:summary="@string/soundbrenner_pref_metronome_toggle_summary"
android:icon="@drawable/ic_play" />
</PreferenceCategory>
<!-- ===================================================================
Tempo
=================================================================== -->
<PreferenceCategory android:title="@string/soundbrenner_category_tempo">
<!--
BPM: 20-300, default 100.
Key: SoundbrennerConstants.PREF_BPM = "soundbrenner_bpm"
-->
<SeekBarPreference
android:key="soundbrenner_bpm"
android:title="@string/soundbrenner_pref_bpm_title"
android:summary="@string/soundbrenner_pref_bpm_summary"
android:defaultValue="100"
app:min="20"
android:max="300"
app:showSeekBarValue="true" />
</PreferenceCategory>
<!-- ===================================================================
Time signature
=================================================================== -->
<PreferenceCategory android:title="@string/soundbrenner_category_time_signature">
<!--
Time signature selector.
Key: SoundbrennerConstants.PREF_TIME_SIGNATURE = "soundbrenner_time_signature"
Entries defined in res/values/arrays.xml (soundbrenner_time_sig_entries / _values).
-->
<ListPreference
android:key="soundbrenner_time_signature"
android:title="@string/soundbrenner_pref_time_sig_title"
android:summary="%s"
android:defaultValue="4/4"
android:entries="@array/soundbrenner_time_sig_entries"
android:entryValues="@array/soundbrenner_time_sig_values" />
</PreferenceCategory>
<!-- ===================================================================
Subdivision
=================================================================== -->
<PreferenceCategory android:title="@string/soundbrenner_category_subdivision">
<!--
Subdivision steps: 1=quarter, 2=eighth, 3=triplet, 4=sixteenth.
Key: SoundbrennerConstants.PREF_SUBDIVISION = "soundbrenner_subdivision"
Entries defined in res/values/arrays.xml.
-->
<ListPreference
android:key="soundbrenner_subdivision"
android:title="@string/soundbrenner_pref_subdivision_title"
android:summary="%s"
android:defaultValue="1"
android:entries="@array/soundbrenner_subdivision_entries"
android:entryValues="@array/soundbrenner_subdivision_values" />
</PreferenceCategory>
<!-- ===================================================================
Beat accent strengths (beats 1-8)
Visibility of beats 2-8 is controlled dynamically by
SoundbrennerSettingsCustomizer based on the selected numerator.
=================================================================== -->
<PreferenceCategory android:title="@string/soundbrenner_category_accents">
<!-- Beat 1 always visible -->
<SeekBarPreference
android:key="soundbrenner_beat_accent_1"
android:title="@string/soundbrenner_pref_beat_1_title"
android:defaultValue="3"
app:min="0"
android:max="3" />
<!-- Beats 2-8: shown/hidden by SoundbrennerSettingsCustomizer -->
<SeekBarPreference
android:key="soundbrenner_beat_accent_2"
android:title="@string/soundbrenner_pref_beat_2_title"
android:defaultValue="1"
app:min="0"
android:max="3" />
<SeekBarPreference
android:key="soundbrenner_beat_accent_3"
android:title="@string/soundbrenner_pref_beat_3_title"
android:defaultValue="1"
app:min="0"
android:max="3" />
<SeekBarPreference
android:key="soundbrenner_beat_accent_4"
android:title="@string/soundbrenner_pref_beat_4_title"
android:defaultValue="1"
app:min="0"
android:max="3" />
<SeekBarPreference
android:key="soundbrenner_beat_accent_5"
android:title="@string/soundbrenner_pref_beat_5_title"
android:defaultValue="1"
app:min="0"
android:max="3" />
<SeekBarPreference
android:key="soundbrenner_beat_accent_6"
android:title="@string/soundbrenner_pref_beat_6_title"
android:defaultValue="1"
app:min="0"
android:max="3" />
<SeekBarPreference
android:key="soundbrenner_beat_accent_7"
android:title="@string/soundbrenner_pref_beat_7_title"
android:defaultValue="1"
app:min="0"
android:max="3" />
<SeekBarPreference
android:key="soundbrenner_beat_accent_8"
android:title="@string/soundbrenner_pref_beat_8_title"
android:defaultValue="1"
app:min="0"
android:max="3" />
</PreferenceCategory>
</PreferenceScreen>