Matson MA98415: Initial support (#6212)

Protocol source: https://github.com/jakka351/Battery-Monitor-Protocol
This commit is contained in:
José Rebelo
2026-05-31 22:16:19 +01:00
parent fff6857367
commit a3dc90ed92
9 changed files with 198 additions and 26 deletions
@@ -158,6 +158,7 @@ public interface DeviceCoordinator {
PV_EQUIPMENT, PV_EQUIPMENT,
GLUCOSE_METER, GLUCOSE_METER,
BLOOD_PRESSURE_METER, BLOOD_PRESSURE_METER,
BATTERY_MONITOR,
} }
/** /**
@@ -0,0 +1,43 @@
package nodomain.freeyourgadget.gadgetbridge.devices.bm2
import nodomain.freeyourgadget.gadgetbridge.R
import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport
import nodomain.freeyourgadget.gadgetbridge.service.devices.bm2.Bm2Support
import java.util.regex.Pattern
class Bm2Coordinator : AbstractBLEDeviceCoordinator() {
override fun getSupportedDeviceName(): Pattern {
return Pattern.compile("^MATSON Monitor$")
}
override fun getManufacturer(): String {
return "Matson"
}
override fun getDeviceSupportClass(device: GBDevice): Class<out DeviceSupport> {
return Bm2Support::class.java
}
override fun getDeviceNameResource(): Int {
return R.string.devicetype_battery_monitor
}
override fun suggestUnbindBeforePair(): Boolean {
return false
}
override fun getBondingStyle(): Int {
return BONDING_STYLE_NONE
}
override fun getDefaultIconResource(): Int {
return R.drawable.ic_device_car
}
override fun getDeviceKind(device: GBDevice): DeviceCoordinator.DeviceKind {
return DeviceCoordinator.DeviceKind.BATTERY_MONITOR
}
}
@@ -35,6 +35,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.atcbleoepl.ATCBLEOEPLCoordin
import nodomain.freeyourgadget.gadgetbridge.devices.bandwpseries.BandWPSeriesDeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.bandwpseries.BandWPSeriesDeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.banglejs.BangleJSCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.banglejs.BangleJSCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.binary_sensor.coordinator.BinarySensorCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.binary_sensor.coordinator.BinarySensorCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.bm2.Bm2Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.braun.BraunBPW4500DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.braun.BraunBPW4500DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.casio.ecbs100.CasioECBS100DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.casio.ecbs100.CasioECBS100DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.casio.gb6900.CasioGB6900DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.casio.gb6900.CasioGB6900DeviceCoordinator;
@@ -827,6 +828,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),
BM2_BATTERY_MONITOR(Bm2Coordinator.class),
SINILINK(SinilinkCoordinator.class), SINILINK(SinilinkCoordinator.class),
ONETOUCH(OneTouchCoordinator.class), ONETOUCH(OneTouchCoordinator.class),
SOUNDCORE_LIBERTY3_PRO(SoundcoreLiberty3ProCoordinator.class), SOUNDCORE_LIBERTY3_PRO(SoundcoreLiberty3ProCoordinator.class),
@@ -589,7 +589,7 @@ public class BleNamesResolver {
mServices.put("16187f00-0000-1000-8000-00807f9b34fb", "(Propr: Xiaomi Wear Service - Mi Smart Watch 4C/Redmi Band)"); mServices.put("16187f00-0000-1000-8000-00807f9b34fb", "(Propr: Xiaomi Wear Service - Mi Smart Watch 4C/Redmi Band)");
mServices.put("1314f000-1000-9000-7000-301291e21220", "(Propr: Xiaomi Wear Service - Mi Watch/Mi Watch Color/Mi Watch Color Sport)"); mServices.put("1314f000-1000-9000-7000-301291e21220", "(Propr: Xiaomi Wear Service - Mi Watch/Mi Watch Color/Mi Watch Color Sport)");
mServices.put("7495fe00-a7f3-424b-92dd-4a006a3aef56", "(Propr: Xiaomi Wear Service - Mi Watch CN)"); mServices.put("7495fe00-a7f3-424b-92dd-4a006a3aef56", "(Propr: Xiaomi Wear Service - Mi Watch CN)");
mServices.put("0000fff0-0000-1000-8000-00805f9b34fb", "(Propr: Nothing CMF Command"); mServices.put("0000fff0-0000-1000-8000-00805f9b34fb", "(Propr: Custom Service)");
mServices.put("02f00000-0000-0000-0000-00000000ffe0", "(Propr: Nothing CMF Data"); mServices.put("02f00000-0000-0000-0000-00000000ffe0", "(Propr: Nothing CMF Data");
mServices.put("02f00000-0000-0000-0000-00000000fe00", "(Propr: Nothing CMF Firmware"); mServices.put("02f00000-0000-0000-0000-00000000fe00", "(Propr: Nothing CMF Firmware");
mServices.put("77d4e67c-2fe2-2334-0d35-9ccd078f529c", "(Propr: Nothing CMF Shell"); mServices.put("77d4e67c-2fe2-2334-0d35-9ccd078f529c", "(Propr: Nothing CMF Shell");
@@ -23,8 +23,13 @@ import android.content.Intent;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID; import java.util.UUID;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdateDeviceInfo;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo;
import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLESingleDeviceSupport; import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLESingleDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
import nodomain.freeyourgadget.gadgetbridge.service.btle.GattCharacteristic; import nodomain.freeyourgadget.gadgetbridge.service.btle.GattCharacteristic;
@@ -177,4 +182,35 @@ public class DeviceInfoProfile<T extends AbstractBTLESingleDeviceSupport> extend
intent.putExtra(EXTRA_DEVICE_INFO, deviceInfo); // TODO: broadcast a clone of the info intent.putExtra(EXTRA_DEVICE_INFO, deviceInfo); // TODO: broadcast a clone of the info
return intent; return intent;
} }
public static List<GBDeviceEvent> toDeviceEvents(final DeviceInfo deviceInfo) {
final GBDeviceEventVersionInfo versionCmd = new GBDeviceEventVersionInfo();
final List<GBDeviceEvent> events = new ArrayList<>(1);
events.add(versionCmd);
if (deviceInfo.getHardwareRevision() != null) {
versionCmd.hwVersion = deviceInfo.getHardwareRevision();
}
if (deviceInfo.getFirmwareRevision() != null) {
versionCmd.fwVersion = deviceInfo.getFirmwareRevision();
versionCmd.fwVersion2 = deviceInfo.getSoftwareRevision();
} else if (deviceInfo.getSoftwareRevision() != null) {
versionCmd.fwVersion = deviceInfo.getSoftwareRevision();
}
if (deviceInfo.getManufacturerName() != null) {
events.add(new GBDeviceEventUpdateDeviceInfo("MANUFACTURER: ", deviceInfo.getManufacturerName()));
}
if (deviceInfo.getModelNumber() != null) {
events.add(new GBDeviceEventUpdateDeviceInfo("MODEL: ", deviceInfo.getModelNumber()));
}
if (deviceInfo.getSerialNumber() != null) {
events.add(new GBDeviceEventUpdateDeviceInfo("SERIAL: ", deviceInfo.getSerialNumber()));
}
return events;
}
} }
@@ -0,0 +1,110 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.bm2
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.content.Intent
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
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.deviceinfo.DeviceInfo
import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.deviceinfo.DeviceInfoProfile
import org.slf4j.LoggerFactory
import java.nio.ByteBuffer
import java.util.UUID
class Bm2Support : AbstractBTLESingleDeviceSupport(LOG) {
private val cipher: Cipher = Cipher.getInstance("AES/CBC/NoPadding").apply {
init(Cipher.DECRYPT_MODE, SecretKeySpec(KEY, "AES"), IvParameterSpec(ByteArray(16)))
}
private val deviceInfoProfile: DeviceInfoProfile<Bm2Support>
init {
val mListener = IntentListener { intent: Intent? ->
intent?.action?.let { action ->
when (action) {
DeviceInfoProfile.ACTION_DEVICE_INFO -> {
@Suppress("DEPRECATION")
var deviceInfo = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(DeviceInfoProfile.EXTRA_DEVICE_INFO, DeviceInfo::class.java)
} else {
intent.getParcelableExtra(DeviceInfoProfile.EXTRA_DEVICE_INFO)
}
LOG.debug("Device info: {}", deviceInfo)
val events = DeviceInfoProfile.toDeviceEvents(deviceInfo)
for (event in events) {
handleGBDeviceEvent(event)
}
}
}
}
}
addSupportedService(UUID_SERVICE_BM2)
addSupportedService(GattService.UUID_SERVICE_DEVICE_INFORMATION)
deviceInfoProfile = DeviceInfoProfile<Bm2Support>(this)
deviceInfoProfile.addListener(mListener)
addSupportedProfile(deviceInfoProfile)
}
override fun useAutoConnect(): Boolean {
return true
}
override fun initializeDevice(builder: TransactionBuilder): TransactionBuilder {
builder.setDeviceState(GBDevice.State.INITIALIZING)
builder.notify(UUID_CHARACTERISTIC_BM2, true)
builder.setDeviceState(GBDevice.State.INITIALIZED)
return builder
}
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray
): Boolean {
if (UUID_CHARACTERISTIC_BM2 == characteristic.uuid) {
val (voltage, level) = parse(cipher.doFinal(value)) ?: run {
return true // Logged upstream
}
LOG.debug("Voltage: {}V, Level: {}%", voltage, level)
val batteryEvent = GBDeviceEventBatteryInfo()
batteryEvent.voltage = voltage
batteryEvent.level = level
evaluateGBDeviceEvent(batteryEvent)
return true
}
return super.onCharacteristicChanged(gatt, characteristic, value)
}
companion object {
private val LOG = LoggerFactory.getLogger(Bm2Support::class.java)
private val UUID_SERVICE_BM2: UUID = UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb")
private val UUID_CHARACTERISTIC_BM2: UUID = UUID.fromString("0000fff4-0000-1000-8000-00805f9b34fb")
private val KEY = byteArrayOf(108, 101, 97, 103, 101, 110, 100, -1, -2, 49, 56, 56, 50, 52, 54, 54)
internal fun parse(decrypted: ByteArray): Pair<Float, Int>? {
val buf = ByteBuffer.wrap(decrypted)
val preamble = buf.get()
if (preamble != 0xf5.toByte()) {
LOG.warn("Unknown preamble 0x{}", preamble.toHexString())
return null
}
val voltageRaw = (buf.getShort().toInt() and 0xFFFF) ushr 4
val level = buf.get().toInt() and 0xFF
val voltage = voltageRaw / 100.0f
return Pair(voltage, level)
}
}
}
@@ -412,31 +412,9 @@ class OneTouchSupport : AbstractBTLESingleDeviceSupport(LOG) {
private fun handleDeviceInfo(deviceInfo: DeviceInfo) { private fun handleDeviceInfo(deviceInfo: DeviceInfo) {
LOG.debug("Device info: {}", deviceInfo) LOG.debug("Device info: {}", deviceInfo)
val versionCmd = GBDeviceEventVersionInfo() val events = DeviceInfoProfile.toDeviceEvents(deviceInfo)
for (event in events) {
if (deviceInfo.hardwareRevision != null) { handleGBDeviceEvent(event)
versionCmd.hwVersion = deviceInfo.hardwareRevision
}
if (deviceInfo.firmwareRevision != null) {
versionCmd.fwVersion = deviceInfo.firmwareRevision
versionCmd.fwVersion2 = deviceInfo.softwareRevision
} else if (deviceInfo.softwareRevision != null) {
versionCmd.fwVersion = deviceInfo.softwareRevision
}
handleGBDeviceEvent(versionCmd)
if (deviceInfo.manufacturerName != null) {
handleGBDeviceEvent(GBDeviceEventUpdateDeviceInfo("MANUFACTURER: ", deviceInfo.manufacturerName))
}
if (deviceInfo.modelNumber != null) {
handleGBDeviceEvent(GBDeviceEventUpdateDeviceInfo("MODEL: ", deviceInfo.modelNumber))
}
if (deviceInfo.serialNumber != null) {
handleGBDeviceEvent(GBDeviceEventUpdateDeviceInfo("SERIAL: ", deviceInfo.serialNumber))
} }
} }
+1
View File
@@ -4644,6 +4644,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_battery_monitor" translatable="false">Battery Monitor</string>
<string name="devicetype_sinilink" translatable="false">Sinilink</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>
@@ -200,6 +200,7 @@ public class AbstractDeviceCoordinatorTest extends TestBase {
put("C 20", DeviceType.C20); // #5495 put("C 20", DeviceType.C20); // #5495
put("OV-Touch2.6_LE", DeviceType.OVTOUCH26); // #5628 put("OV-Touch2.6_LE", DeviceType.OVTOUCH26); // #5628
put("BPW4500", DeviceType.BRAUN_BPW4500); // #5886 put("BPW4500", DeviceType.BRAUN_BPW4500); // #5886
put("MATSON Monitor", DeviceType.BM2_BATTERY_MONITOR); // #6212
}}; }};
for (Map.Entry<String, DeviceType> e : bluetoothNameToExpectedType.entrySet()) { for (Map.Entry<String, DeviceType> e : bluetoothNameToExpectedType.entrySet()) {