From a3dc90ed92d10d9b12d4b543ce71d02fe7be7df1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Rebelo?= Date: Sun, 31 May 2026 22:15:18 +0100 Subject: [PATCH] Matson MA98415: Initial support (#6212) Protocol source: https://github.com/jakka351/Battery-Monitor-Protocol --- .../devices/DeviceCoordinator.java | 1 + .../devices/bm2/Bm2Coordinator.kt | 43 +++++++ .../gadgetbridge/model/DeviceType.java | 2 + .../service/btle/BleNamesResolver.java | 2 +- .../deviceinfo/DeviceInfoProfile.java | 36 ++++++ .../service/devices/bm2/Bm2Support.kt | 110 ++++++++++++++++++ .../devices/onetouch/OneTouchSupport.kt | 28 +---- app/src/main/res/values/strings.xml | 1 + .../AbstractDeviceCoordinatorTest.java | 1 + 9 files changed, 198 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/bm2/Bm2Coordinator.kt create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/bm2/Bm2Support.kt diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java index 926fe3adf6..31d1139bb8 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java @@ -158,6 +158,7 @@ public interface DeviceCoordinator { PV_EQUIPMENT, GLUCOSE_METER, BLOOD_PRESSURE_METER, + BATTERY_MONITOR, } /** diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/bm2/Bm2Coordinator.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/bm2/Bm2Coordinator.kt new file mode 100644 index 0000000000..c6b7503e81 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/bm2/Bm2Coordinator.kt @@ -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 { + 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 + } +} 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 835121667a..af179688b3 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java @@ -35,6 +35,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.atcbleoepl.ATCBLEOEPLCoordin import nodomain.freeyourgadget.gadgetbridge.devices.bandwpseries.BandWPSeriesDeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.banglejs.BangleJSCoordinator; 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.casio.ecbs100.CasioECBS100DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.casio.gb6900.CasioGB6900DeviceCoordinator; @@ -827,6 +828,7 @@ public enum DeviceType { SONY_WH_CH720N(SonyWHCH720NCoordinator.class), PIXEL_BUDS_A(PixelBudsACoordinator.class), SHOKZ_OPENSWIM_PRO(ShokzOpenSwimProCoordinator.class), + BM2_BATTERY_MONITOR(Bm2Coordinator.class), SINILINK(SinilinkCoordinator.class), ONETOUCH(OneTouchCoordinator.class), SOUNDCORE_LIBERTY3_PRO(SoundcoreLiberty3ProCoordinator.class), diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BleNamesResolver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BleNamesResolver.java index 784648dccc..145b1655bd 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BleNamesResolver.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BleNamesResolver.java @@ -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("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("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-00000000fe00", "(Propr: Nothing CMF Firmware"); mServices.put("77d4e67c-2fe2-2334-0d35-9ccd078f529c", "(Propr: Nothing CMF Shell"); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/deviceinfo/DeviceInfoProfile.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/deviceinfo/DeviceInfoProfile.java index 35aa357743..b325d9e873 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/deviceinfo/DeviceInfoProfile.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/deviceinfo/DeviceInfoProfile.java @@ -23,8 +23,13 @@ import android.content.Intent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.ArrayList; +import java.util.List; 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.BLETypeConversions; import nodomain.freeyourgadget.gadgetbridge.service.btle.GattCharacteristic; @@ -177,4 +182,35 @@ public class DeviceInfoProfile extend intent.putExtra(EXTRA_DEVICE_INFO, deviceInfo); // TODO: broadcast a clone of the info return intent; } + + public static List toDeviceEvents(final DeviceInfo deviceInfo) { + final GBDeviceEventVersionInfo versionCmd = new GBDeviceEventVersionInfo(); + final List 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; + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/bm2/Bm2Support.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/bm2/Bm2Support.kt new file mode 100644 index 0000000000..66ff105894 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/bm2/Bm2Support.kt @@ -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 + + 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(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? { + 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) + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/onetouch/OneTouchSupport.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/onetouch/OneTouchSupport.kt index e6d4dccea0..8d9acb4765 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/onetouch/OneTouchSupport.kt +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/onetouch/OneTouchSupport.kt @@ -412,31 +412,9 @@ class OneTouchSupport : AbstractBTLESingleDeviceSupport(LOG) { private fun handleDeviceInfo(deviceInfo: DeviceInfo) { LOG.debug("Device info: {}", deviceInfo) - val versionCmd = GBDeviceEventVersionInfo() - - if (deviceInfo.hardwareRevision != null) { - 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)) + val events = DeviceInfoProfile.toDeviceEvents(deviceInfo) + for (event in events) { + handleGBDeviceEvent(event) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 26181499cf..389bc6d281 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4644,6 +4644,7 @@ Could not open URL Authentication settings Shokz OpenSwim Pro + Battery Monitor Sinilink OneTouch Glucose Meter Standard diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinatorTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinatorTest.java index 24e5337235..779569455a 100644 --- a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinatorTest.java +++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinatorTest.java @@ -200,6 +200,7 @@ public class AbstractDeviceCoordinatorTest extends TestBase { put("C 20", DeviceType.C20); // #5495 put("OV-Touch2.6_LE", DeviceType.OVTOUCH26); // #5628 put("BPW4500", DeviceType.BRAUN_BPW4500); // #5886 + put("MATSON Monitor", DeviceType.BM2_BATTERY_MONITOR); // #6212 }}; for (Map.Entry e : bluetoothNameToExpectedType.entrySet()) {