WeightScale: add btle profile and generic device coordinator / device support (#5225)

Adds support for receiving weights from standard Bluetooth Weight scales:
0000181d-0000-1000-8000-00805f9b34fb - org.bluetooth.service.weight_scale
00002a9d-0000-1000-8000-00805f9b34fb - org.bluetooth.characteristic.weight_measurement
https://www.bluetooth.com/specifications/specs/weight-scale-service-1-0-1/
This commit is contained in:
Thomas Kuehne
2025-08-18 11:23:52 +02:00
committed by José Rebelo
parent 7eaa5bb76b
commit 5b1a4b237b
18 changed files with 1070 additions and 3 deletions
@@ -58,7 +58,7 @@ public class GBDaoGenerator {
public static void main(String[] args) throws Exception {
final Schema schema = new Schema(112, MAIN_PACKAGE + ".entities");
final Schema schema = new Schema(113, MAIN_PACKAGE + ".entities");
Entity userAttributes = addUserAttributes(schema);
Entity user = addUserInfo(schema, userAttributes);
@@ -211,6 +211,7 @@ public class GBDaoGenerator {
addGenericSleepStageSample(schema, user, device);
addGenericTrainingLoadAcuteSample(schema, user, device);
addGenericTrainingLoadChronicSample(schema, user, device);
addGenericWeightSample(schema, user, device);
new DaoGenerator().generateAll(schema, "app/src/main/java");
}
@@ -1967,4 +1968,11 @@ public class GBDaoGenerator {
sample.addIntProperty("value").notNull();
return sample;
}
private static Entity addGenericWeightSample(Schema schema, Entity user, Entity device) {
Entity sample = addEntity(schema, "GenericWeightSample");
addCommonTimeSampleProperties("AbstractWeightSample", sample, user, device);
sample.addFloatProperty(SAMPLE_WEIGHT_KG).notNull().codeBeforeGetter(OVERRIDE);
return sample;
}
}
+4
View File
@@ -279,6 +279,10 @@
android:name=".devices.ultrahuman.UltrahumanBreathingActivity"
android:label="@string/devicetype_ultrahuma_ring_air"
android:parentActivityName=".activities.ControlCenterv2" />
<activity
android:name=".devices.generic_scale.GenericWeightScaleMeasurementActivity"
android:label="@string/devicetype_generic_weight_scale"
android:parentActivityName=".activities.ControlCenterv2" />
<activity
android:name=".devices.thermalprinter.SendToPrinterActivity"
android:label="@string/devicetype_generic_thermal_printer"
@@ -90,6 +90,7 @@ public class DeviceSettingsPreferenceConst {
public static final String PREF_CALENDAR_MAX_DESC_LENGTH = "calendar_sync_event_desc_length";
public static final String PREF_CALENDAR_TARGET_APP = "calendar_sync_target_app";
public static final String PREF_TIME_SYNC = "time_sync";
public static final String PREF_WEIGHT_SCALE_UNIT = "pref_weight_scale_unit";
public static final String PREF_USE_CUSTOM_DEVICEICON = "use_custom_deviceicon";
public static final String PREF_BUTTON_1_FUNCTION_SHORT = "button_1_function_short";
public static final String PREF_BUTTON_2_FUNCTION_SHORT = "button_2_function_short";
@@ -1,7 +1,7 @@
/* Copyright (C) 2019-2024 akasaka / Genjitsu Labs, Alicia Hormann, Andreas
/* Copyright (C) 2019-2025 akasaka / Genjitsu Labs, Alicia Hormann, Andreas
Böhler, Andreas Shimokawa, Arjan Schrijver, Cre3per, Damien Gaignon, Daniel
Dakhno, Daniele Gobbetti, Davis Mosenkovs, foxstidious, José Rebelo, mamucho,
NekoBox, opavlov, Petr Vaněk, Yoran Vulker, Yukai Li, Zhong Jianxin
NekoBox, opavlov, Petr Vaněk, Yoran Vulker, Yukai Li, Zhong Jianxin, Thomas Kuehne
This file is part of Gadgetbridge.
@@ -427,6 +427,11 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i
DeviceSettingsUtils.sortListPreference(transliterationPreference, false);
}
final ListPreference weightScaleUnitPreference = findPreference(PREF_WEIGHT_SCALE_UNIT);
if (weightScaleUnitPreference != null) {
DeviceSettingsUtils.sortListPreference(weightScaleUnitPreference, false);
}
String disconnectNotificationState = prefs.getString(PREF_DISCONNECT_NOTIFICATION, PREF_DO_NOT_DISTURB_OFF);
boolean disconnectNotificationScheduled = disconnectNotificationState.equals(PREF_DO_NOT_DISTURB_SCHEDULED);
@@ -0,0 +1,43 @@
/* Copyright (C) 2025 Thomas Kuehne
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.devices
import de.greenrobot.dao.AbstractDao
import de.greenrobot.dao.Property
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession
import nodomain.freeyourgadget.gadgetbridge.entities.GenericWeightSample
import nodomain.freeyourgadget.gadgetbridge.entities.GenericWeightSampleDao
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
class GenericWeightSampleProvider(device: GBDevice?, session: DaoSession?) :
AbstractTimeSampleProvider<GenericWeightSample?>(device, session) {
override fun getSampleDao(): AbstractDao<GenericWeightSample?, *> {
return session.genericWeightSampleDao
}
override fun getTimestampSampleProperty(): Property {
return GenericWeightSampleDao.Properties.Timestamp
}
override fun getDeviceIdentifierSampleProperty(): Property {
return GenericWeightSampleDao.Properties.DeviceId
}
override fun createSample(): GenericWeightSample {
return GenericWeightSample()
}
}
@@ -0,0 +1,44 @@
/* Copyright (C) 2025 Thomas Kuehne
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.devices.generic_scale
import android.content.Context
import android.content.Intent
import nodomain.freeyourgadget.gadgetbridge.R
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCardAction
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.weightScale.WeightScaleProfile
class GenericWeightScaleAction : DeviceCardAction {
override fun getIcon(device: GBDevice?): Int {
return R.drawable.ic_balance
}
override fun getDescription(
device: GBDevice?, context: Context?
): String? {
return context?.getString(R.string.weight_scale_show_measurement)
}
override fun onClick(
device: GBDevice?, context: Context?
) {
val intent = Intent(context, GenericWeightScaleMeasurementActivity::class.java)
intent.putExtra(WeightScaleProfile.EXTRA_ADDRESS, device?.address)
context?.startActivity(intent)
}
}
@@ -0,0 +1,113 @@
/* Copyright (C) 2025 Thomas Kuehne
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.devices.generic_scale
import de.greenrobot.dao.AbstractDao
import de.greenrobot.dao.Property
import nodomain.freeyourgadget.gadgetbridge.R
import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCardAction
import nodomain.freeyourgadget.gadgetbridge.devices.GenericWeightSampleProvider
import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession
import nodomain.freeyourgadget.gadgetbridge.entities.GenericWeightSampleDao
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate
import nodomain.freeyourgadget.gadgetbridge.model.WeightSample
import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport
import nodomain.freeyourgadget.gadgetbridge.service.btle.GattService
import nodomain.freeyourgadget.gadgetbridge.service.devices.generic_scale.GenericWeightScaleSupport
import java.util.Collections
class GenericWeightScaleCoordinator : AbstractBLEDeviceCoordinator() {
override fun getAllDeviceDao(session: DaoSession): MutableMap<AbstractDao<*, *>?, Property?> {
return Collections.singletonMap(
session.genericWeightSampleDao, GenericWeightSampleDao.Properties.DeviceId
)
}
override fun getCustomActions(): MutableList<DeviceCardAction?> {
return Collections.singletonList<DeviceCardAction>(GenericWeightScaleAction())
}
override fun getOrderPriority(): Int {
return Int.MAX_VALUE
}
override fun getSupportedDeviceSpecificSettings(device: GBDevice?): IntArray? {
return intArrayOf(R.xml.devicesettings_weight_scale_unit)
}
override fun getWeightSampleProvider(
device: GBDevice?, session: DaoSession?
): TimeSampleProvider<out WeightSample?>? {
return GenericWeightSampleProvider(device, session)
}
override fun isExperimental(): Boolean {
// needs more testing
return true
}
override fun getManufacturer(): String? {
return "Generic"
}
override fun getDeviceSupportClass(device: GBDevice?): Class<out DeviceSupport?> {
return GenericWeightScaleSupport::class.java
}
override fun getBondingStyle(): Int {
return BONDING_STYLE_ASK
}
override fun getDefaultIconResource(): Int {
return R.drawable.ic_device_miscale
}
override fun getDeviceNameResource(): Int {
return R.string.devicetype_generic_weight_scale
}
override fun supports(candidate: GBDeviceCandidate): Boolean {
return candidate.supportsService(GattService.UUID_SERVICE_WEIGHT_SCALE)
}
override fun supportsWeightMeasurement(device: GBDevice): Boolean {
return true
}
override fun supportsActivityTracking(device: GBDevice): Boolean {
return true
}
override fun supportsActivityTabs(device: GBDevice): Boolean {
return false
}
override fun supportsSleepMeasurement(device: GBDevice): Boolean {
return false
}
override fun supportsStepCounter(device: GBDevice): Boolean {
return false
}
override fun supportsSpeedzones(device: GBDevice): Boolean {
return false
}
}
@@ -0,0 +1,177 @@
/* Copyright (C) 2025 Thomas Kuehne
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.devices.generic_scale
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle
import android.view.View
import android.widget.Button
import android.widget.TextView
import androidx.core.content.ContextCompat
import nodomain.freeyourgadget.gadgetbridge.GBApplication
import nodomain.freeyourgadget.gadgetbridge.R
import nodomain.freeyourgadget.gadgetbridge.activities.AbstractGBActivity
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper
import nodomain.freeyourgadget.gadgetbridge.devices.GenericWeightSampleProvider
import nodomain.freeyourgadget.gadgetbridge.entities.GenericWeightSample
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.weightScale.WeightScaleMeasurement
import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.weightScale.WeightScaleProfile
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.time.Instant
import kotlin.math.roundToInt
class GenericWeightScaleMeasurementActivity : AbstractGBActivity() {
private val weightUpdatedReceiver: WeightUpdatedReceiver = WeightUpdatedReceiver()
private var actual: TextView? = null
private var save: Button? = null
private var device: GBDevice? = null
private var unit: String? = null
private var measurement: WeightScaleMeasurement? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val address = intent.getStringExtra(WeightScaleProfile.EXTRA_ADDRESS)
?: throw IllegalArgumentException(WeightScaleProfile.EXTRA_ADDRESS + " must not be null")
val manager = GBApplication.app().deviceManager
device = manager.getDeviceByAddress(address)
val settings = GBApplication.getDevicePrefs(device)
unit = settings.getString(DeviceSettingsPreferenceConst.PREF_WEIGHT_SCALE_UNIT, null)
if (unit == null) {
unit = if (GBApplication.getPrefs().isMetricUnits) "kilogram" else "pound"
}
setContentView(R.layout.activity_weight_scale_measurement)
save = findViewById(R.id.weight_scale_measurement_save)
save?.setOnClickListener(OnSaveClicked())
actual = findViewById(R.id.weight_scale_measurement_actual)
val filter = IntentFilter()
filter.addAction(WeightScaleProfile.ACTION_WEIGHT_MEASUREMENT)
ContextCompat.registerReceiver(
applicationContext, weightUpdatedReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED
)
displayWeightInfo()
}
internal fun displayWeightInfo() {
val raw: Double? = measurement?.weightKilogram
val kg: Double = if (raw == null || raw.isNaN()) 0.0 else raw
if (unit.equals("jin")) {
val jin: Double = kg * 2
actual?.text = getString(R.string.weight_scale_jin_format, jin)
} else if (unit.equals("pound")) {
val pound: Double = kg / 0.45359237
actual?.text = getString(R.string.weight_scale_pound_format, pound)
} else if (unit.equals("stone")) {
val total: Int = (kg / 0.45359237).roundToInt()
val stone: Int = total / 14
val pound: Int = total % 14
actual?.text = getString(R.string.weight_scale_stone_format, stone, pound)
} else {
actual?.text = getString(R.string.weight_scale_kilogram_format, kg)
}
}
internal fun saveWeightInfo(measurement: WeightScaleMeasurement) {
LOG.debug("saveWeightInfo - {}", measurement)
// ignore: missing mandatory information
if (measurement.weightKilogram == null) {
return
}
// fix time if missing or broken
if (measurement.time == null) {
measurement.time = Instant.now()
} else {
// older or newer than 10 minutes - the time on the scale is likely out of sync
val tooOld = Instant.now().minusSeconds(60L * 10L)
val tooNew = Instant.now().plusSeconds(60L * 10L)
if (measurement.time!!.isBefore(tooOld) || measurement.time!!.isAfter(tooNew)) {
measurement.time = Instant.now()
}
}
try {
GBApplication.acquireDB().use { db ->
val provider = GenericWeightSampleProvider(device, db.getDaoSession())
val userId = DBHelper.getUser(db.getDaoSession()).id
val deviceId = DBHelper.getDevice(device, db.getDaoSession()).id
val sample = GenericWeightSample(
measurement.time!!.epochSecond,
deviceId,
userId,
measurement.weightKilogram!!.toFloat()
)
provider.addSample(sample)
}
LOG.debug("saveWeightInfo - saved {} kg", measurement.weightKilogram)
} catch (e: Exception) {
LOG.error("saveWeightInfo - error", e)
}
}
private inner class OnSaveClicked : View.OnClickListener {
override fun onClick(v: View?) {
measurement?.let {
save?.setEnabled(false)
saveWeightInfo(it)
}
}
}
public override fun onDestroy() {
applicationContext.unregisterReceiver(weightUpdatedReceiver)
super.onDestroy()
}
private inner class WeightUpdatedReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent) {
LOG.debug("received measurement")
val data: WeightScaleMeasurement? =
intent.getParcelableExtra(WeightScaleProfile.EXTRA_WEIGHT_MEASUREMENT)
val address: String? = intent.getStringExtra(WeightScaleProfile.EXTRA_ADDRESS)
if (device?.address?.equals(address) == true) {
measurement = data
displayWeightInfo()
save?.setEnabled(measurement?.weightKilogram != null)
}
}
}
companion object {
private val LOG: Logger =
LoggerFactory.getLogger(GenericWeightScaleMeasurementActivity::class.java)
}
}
@@ -146,6 +146,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.vivosmart.Gar
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.vivosport.GarminVivosportCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.generic_headphones.GenericHeadphonesCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.generic_hr.GenericHeartRateCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.generic_scale.GenericWeightScaleCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.gloryfit.watches.DotnP66DCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.gloryfit.watches.OukitelBT103Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.gree.GreeAcCoordinator;
@@ -781,6 +782,7 @@ public enum DeviceType {
COOSPO_H6(CoospoH6Coordinator.class),
COOSPO_HW9(CoospoHW9Coordinator.class),
COOSPO_HW807(CoospoHW807Coordinator.class),
GENERIC_WEIGHT_SCALE(GenericWeightScaleCoordinator.class),
TEST(TestDeviceCoordinator.class);
private DeviceCoordinator coordinator;
@@ -0,0 +1,40 @@
/* Copyright (C) 2025 Thomas Kuehne
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.weightScale
import android.os.Parcelable
import androidx.annotation.FloatRange
import androidx.annotation.IntRange
import kotlinx.parcelize.Parcelize
import java.time.Instant
@Parcelize
class WeightScaleMeasurement(
@field:FloatRange(from = 0.0) var weightKilogram: Double?,
var time: Instant?,
@field:IntRange(from = 0L, to = 255L) var userId: Int?,
@field:FloatRange(from = 0.0) var heightMeter: Float?,
@field:FloatRange(from = 0.0) var BMI: Float?
) : Parcelable {
constructor() : this(null, null, null, null, null)
override fun toString(): String {
return "$weightKilogram kg, $time, $userId #, $BMI, $heightMeter m"
}
}
@@ -0,0 +1,184 @@
/* Copyright (C) 2025 Thomas Kuehne
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.weightScale;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCharacteristic;
import android.content.Intent;
import androidx.annotation.NonNull;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.time.Instant;
import java.util.GregorianCalendar;
import java.util.UUID;
import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLESingleDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
import nodomain.freeyourgadget.gadgetbridge.service.btle.GattCharacteristic;
import nodomain.freeyourgadget.gadgetbridge.service.btle.GattService;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.AbstractBleProfile;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
/// Supports receiving weights from the standard Bluetooth Weight Scale Service.
/// <ul>
/// <li>org.bluetooth.service.weight_scale / org.bluetooth.characteristic.weight_measurement</li>
/// </ul>
///
/// Weight Scales optionally support {@link #setTime(TransactionBuilder)}.
///
/// @link https://www.bluetooth.com/specifications/specs/weight-scale-service-1-0-1/
/// @see GattService#UUID_SERVICE_WEIGHT_SCALE
/// @see GattCharacteristic#UUID_CHARACTERISTIC_WEIGHT_MEASUREMENT
// TODO also support weight via Body Composition Service:
// org.bluetooth.service.body_composition / org.bluetooth.characteristic.body_composition_measurement
// https://www.bluetooth.com/specifications/specs/body-composition-service-bcs/
// GattService#UUID_SERVICE_BODY_COMPOSITION
public class WeightScaleProfile<T extends AbstractBTLESingleDeviceSupport> extends AbstractBleProfile<T> {
/// posted whenever a weight is measured
public static final String ACTION_WEIGHT_MEASUREMENT = BuildConfig.APPLICATION_ID + "_WEIGHT_MEASUREMENT";
public static final String EXTRA_WEIGHT_MEASUREMENT = "WEIGHT_MEASUREMENT";
public static final String EXTRA_ADDRESS = "ADDRESS";
private static final Logger LOG = LoggerFactory.getLogger(WeightScaleProfile.class);
public WeightScaleProfile(T support) {
super(support);
}
public boolean weightMeasurementIsImperial(int flags) {
return 0 != (flags & 1);
}
@Override
public void enableNotify(TransactionBuilder builder, boolean enable) {
builder.notify(GattCharacteristic.UUID_CHARACTERISTIC_WEIGHT_MEASUREMENT, enable);
}
@Override
public boolean onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, byte[] value, int status) {
return process(characteristic, value);
}
@Override
public boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, byte[] value) {
return process(characteristic, value);
}
private boolean process(BluetoothGattCharacteristic characteristic, byte[] value) {
UUID uuid = characteristic.getUuid();
if (GattCharacteristic.UUID_CHARACTERISTIC_WEIGHT_MEASUREMENT.equals(uuid)) {
return processWeightMeasurement(value);
}
return false;
}
public boolean processWeightMeasurement(byte[] value) {
LOG.debug("weight measurement - {}", GB.hexdump(value));
ByteBuffer buffer = ByteBuffer.wrap(value);
buffer.order(ByteOrder.LITTLE_ENDIAN);
while (buffer.remaining() >= 3) {
WeightScaleMeasurement info = new WeightScaleMeasurement();
int flags = 0xFF & buffer.get();
int rawWeight = 0xFFFF & buffer.getShort();
// 0xFFFF / 65535 is 'measurement unsuccessful'
if (rawWeight != 0xFFFF) {
info.setWeightKilogram(decodeWeightMeasurement(rawWeight, flags));
}
if (0 == (flags & (1 << 1))) {
info.setTime(Instant.now());
} else {
byte[] rawTimestamp = new byte[7];
buffer.get(rawTimestamp);
GregorianCalendar calendar = BLETypeConversions.rawBytesToCalendar(rawTimestamp);
info.setTime(Instant.ofEpochMilli(calendar.getTimeInMillis()));
}
if (0 != (flags & (1 << 2))) {
int user = 0xFF & buffer.get();
// 0xFF / 256 is 'unknown user'
if (user != 0xFF) {
info.setUserId(user);
}
}
if (0 != (flags & (1 << 3))) {
// unitless with a resolution of 0.1
int rawBmi = 0xFFFF & buffer.getShort();
info.setBMI((float) (0.1 * rawBmi));
int rawHeight = 0xFFFF & buffer.getShort();
if (weightMeasurementIsImperial(flags)) {
// inches with a resolution of 0.1
info.setHeightMeter((float) (rawHeight * 0.1 * 0.0254));
} else {
// meters with a resolution of 0.001
info.setHeightMeter((float) (rawHeight * 0.001));
}
}
LOG.debug("weight measurement - {}", info);
Intent intent = createIntent(info);
notify(intent);
}
if (buffer.remaining() > 0) {
LOG.warn("weight measurement - {} undecoded trailing bytes: {}", buffer.remaining(), GB.hexdump(value));
}
return true;
}
@Nullable
protected Double decodeWeightMeasurement(int rawWeight, int flags) {
if (rawWeight == 0xFFFF) {
// 0xFFFF / 65535 is 'measurement unsuccessful'
return null;
}
if (weightMeasurementIsImperial(flags)) {
// pounds with a resolution of 0.01
return rawWeight * 0.01 * 0.45359237;
} else {
// kilograms with a resolution of 0.005
return rawWeight * 0.005;
}
}
public void setTime(@NonNull TransactionBuilder builder) {
GregorianCalendar now = BLETypeConversions.createCalendar();
byte[] time = BLETypeConversions.calendarToCurrentTime(now, 0);
builder.write(GattCharacteristic.UUID_CHARACTERISTIC_CURRENT_TIME, time);
}
public void readWeight(@NonNull TransactionBuilder builder) {
builder.read(GattCharacteristic.UUID_CHARACTERISTIC_WEIGHT_MEASUREMENT);
}
protected Intent createIntent(final WeightScaleMeasurement measurement) {
final Intent intent = new Intent(ACTION_WEIGHT_MEASUREMENT);
intent.putExtra(EXTRA_WEIGHT_MEASUREMENT, measurement);
return intent;
}
}
@@ -0,0 +1,137 @@
/* Copyright (C) 2025 Thomas Kuehne
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.generic_scale
import android.content.Intent
import nodomain.freeyourgadget.gadgetbridge.GBApplication
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLESingleDeviceSupport
import nodomain.freeyourgadget.gadgetbridge.service.btle.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
import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.deviceinfo.DeviceInfo
import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.deviceinfo.DeviceInfoProfile
import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.weightScale.WeightScaleProfile
import org.slf4j.Logger
import org.slf4j.LoggerFactory
open class GenericWeightScaleSupport(logger: Logger = LOG) :
AbstractBTLESingleDeviceSupport(logger) {
private var mBatteryInfoProfile: BatteryInfoProfile<GenericWeightScaleSupport>
private var mDeviceInfoProfile: DeviceInfoProfile<GenericWeightScaleSupport>
private var mWeightScaleProfile: WeightScaleProfile<GenericWeightScaleSupport>
init {
val mListener = IntentListener { intent: Intent? ->
intent?.action?.let { action ->
when (action) {
DeviceInfoProfile.ACTION_DEVICE_INFO -> {
handleDeviceInfo(intent.getParcelableExtra(DeviceInfoProfile.EXTRA_DEVICE_INFO)!!)
}
BatteryInfoProfile.ACTION_BATTERY_INFO -> {
handleBatteryInfo(intent.getParcelableExtra(BatteryInfoProfile.EXTRA_BATTERY_INFO)!!)
}
WeightScaleProfile.ACTION_WEIGHT_MEASUREMENT -> {
LOG.debug("received weight")
intent.putExtra(WeightScaleProfile.EXTRA_ADDRESS, device.address)
intent.setPackage(context.packageName)
context.sendBroadcast(intent)
}
}
}
}
addSupportedService(GattService.UUID_SERVICE_DEVICE_INFORMATION)
mDeviceInfoProfile = DeviceInfoProfile<GenericWeightScaleSupport>(this)
mDeviceInfoProfile.addListener(mListener)
addSupportedProfile(mDeviceInfoProfile)
addSupportedService(GattService.UUID_SERVICE_BATTERY_SERVICE)
mBatteryInfoProfile = BatteryInfoProfile<GenericWeightScaleSupport>(this)
mBatteryInfoProfile.addListener(mListener)
addSupportedProfile(mBatteryInfoProfile)
addSupportedService(GattService.UUID_SERVICE_WEIGHT_SCALE)
mWeightScaleProfile = WeightScaleProfile<GenericWeightScaleSupport>(this)
mWeightScaleProfile.addListener(mListener)
addSupportedProfile(mWeightScaleProfile)
}
override fun initializeDevice(builder: TransactionBuilder): TransactionBuilder {
if (device.firmwareVersion == null) {
device.firmwareVersion = "N/A"
device.firmwareVersion2 = "N/A"
}
builder.setDeviceState(GBDevice.State.INITIALIZING)
mDeviceInfoProfile.requestDeviceInfo(builder)
mBatteryInfoProfile.requestBatteryInfo(builder)
mBatteryInfoProfile.enableNotify(builder, true)
if (GBApplication.getPrefs().syncTime()) {
mWeightScaleProfile.setTime(builder)
}
mWeightScaleProfile.enableNotify(builder, true)
builder.setDeviceState(GBDevice.State.INITIALIZED)
return builder
}
private fun handleBatteryInfo(info: BatteryInfo) {
LOG.debug("handleBatteryInfo: {}", info)
val batteryCmd = GBDeviceEventBatteryInfo()
batteryCmd.level = info.percentCharged
handleGBDeviceEvent(batteryCmd)
}
private fun handleDeviceInfo(deviceInfo: DeviceInfo) {
LOG.debug("handleDeviceInfo: {}", deviceInfo)
val versionCmd = GBDeviceEventVersionInfo()
if (deviceInfo.hardwareRevision != null) {
versionCmd.hwVersion = deviceInfo.hardwareRevision
}
if (deviceInfo.firmwareRevision != null) {
versionCmd.fwVersion = deviceInfo.firmwareRevision
}
if (deviceInfo.softwareRevision != null) {
versionCmd.fwVersion2 = deviceInfo.softwareRevision
}
handleGBDeviceEvent(versionCmd)
}
override fun useAutoConnect(): Boolean {
return false
}
companion object {
private val LOG: Logger = LoggerFactory.getLogger(GenericWeightScaleSupport::class.java)
}
}
+10
View File
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M80,840L80,760L440,760L440,313Q414,304 395,285Q376,266 367,240L240,240L360,520Q360,570 319,605Q278,640 220,640Q162,640 121,605Q80,570 80,520L200,240L120,240L120,160L367,160Q379,125 410,102.5Q441,80 480,80Q519,80 550,102.5Q581,125 593,160L840,160L840,240L760,240L880,520Q880,570 839,605Q798,640 740,640Q682,640 641,605Q600,570 600,520L720,240L593,240Q584,266 565,285Q546,304 520,313L520,760L880,760L880,840L80,840ZM665,520L815,520L740,346L665,520ZM145,520L295,520L220,346L145,520ZM480,240Q497,240 508.5,228.5Q520,217 520,200Q520,183 508.5,171.5Q497,160 480,160Q463,160 451.5,171.5Q440,183 440,200Q440,217 451.5,228.5Q463,240 480,240Z"/>
</vector>
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center_horizontal"
android:orientation="vertical">
<TextView
android:id="@+id/weight_scale_measurement_actual"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_margin="@dimen/fab_margin"
android:layout_weight="6"
android:autoSizeTextType="uniform"
android:gravity="center"
android:lines="1"
android:text="@string/weight_scale_unknown"
android:textIsSelectable="true" />
<Button
android:id="@+id/weight_scale_measurement_save"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_margin="@dimen/fab_margin"
android:layout_weight="1"
android:autoSizeTextType="uniform"
android:enabled="false"
android:text="@string/record_data" />
</LinearLayout>
+14
View File
@@ -5230,4 +5230,18 @@
<item>8000</item>
<item>10000</item>
</string-array>
<string-array name="weight_scale_units">
<item>@string/weight_scale_jin</item>
<item>@string/weight_scale_kilogram</item>
<item>@string/weight_scale_pound</item>
<item>@string/weight_scale_stone</item>
</string-array>
<string-array name="weight_scale_values">
<item>jin</item>
<item>kilogram</item>
<item>pound</item>
<item>stone</item>
</string-array>
</resources>
+12
View File
@@ -2130,6 +2130,7 @@
<string name="devicetype_generic_thermal_printer">Thermal Printer</string>
<string name="devicetype_generic_headphones">Headphones</string>
<string name="devicetype_generic_heart_rate_sensor">Heart Rate Sensor</string>
<string name="devicetype_generic_weight_scale">Weight Scale</string>
<string name="devicetype_coospo_h6" translatable="false">Coospo H6</string>
<string name="devicetype_coospo_hw9" translatable="false">Coospo HW9</string>
<string name="devicetype_coospo_hw807" translatable="false">Coospo HW807</string>
@@ -4108,6 +4109,17 @@
<string name="ultrahuman_breathing_stop">Stop</string>
<string name="ultrahuman_unhandled_error_response">unhandled response %2$#02x %3$#02x for operation %1$#02x</string>
<string name="ultrahuman_unhandled_operation_response">unhandled operation response %s</string>
<string name="weight_scale_jin">jin - 市斤</string>
<string name="weight_scale_jin_format">%1$.2f 市斤</string>
<string name="weight_scale_kilogram">kilogram - kg</string>
<string name="weight_scale_kilogram_format">%1$.3f kg</string>
<string name="weight_scale_pound">pound - lb</string>
<string name="weight_scale_pound_format">%1$.3f lb</string>
<string name="weight_scale_show_measurement">Show Weight</string>
<string name="weight_scale_stone">stone - st</string>
<string name="weight_scale_stone_format">%1$d st %2$d lb</string>
<string name="weight_scale_unknown">\?\?\?.\?\?\? \?\?</string>
<string name="record_data">Save</string>
<string name="huawei_stress_test_enable">Automatic stress test</string>
<string name="huawei_stress_test_enable_summary">Enable or disable automatic stress test</string>
<string name="huawei_stress_no_calibration_data">No initial data - calibrate first</string>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<ListPreference
android:entries="@array/weight_scale_units"
android:entryValues="@array/weight_scale_values"
android:icon="@drawable/ic_weight"
android:key="pref_weight_scale_unit"
android:title="@string/pref_header_display"
app:useSimpleSummaryProvider="true" />
</androidx.preference.PreferenceScreen>
@@ -0,0 +1,233 @@
/* Copyright (C) 2025 Thomas Kuehne
This file is part of Gadgetbridge.
Gadgetbridge is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
Gadgetbridge is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.weightScale;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import android.content.Intent;
import org.hamcrest.Matchers;
import org.junit.Before;
import org.junit.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.TimeZone;
import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLESingleDeviceSupport;
public class WeightScaleScaleProfileTest extends AbstractBTLESingleDeviceSupport {
private static final Logger LOG = LoggerFactory.getLogger(WeightScaleScaleProfileTest.class);
private ArrayList<WeightScaleMeasurement> Results;
public WeightScaleScaleProfileTest() {
super(LOG);
}
@Before
public void Setup() {
Results = new ArrayList<>(10);
}
@Test
public void simple() {
WeightScaleProfile<WeightScaleScaleProfileTest> weightScaleProfile = new WeightScaleProfileProxy(this);
weightScaleProfile.processWeightMeasurement(new byte[]{(byte) 0, (byte) 0, (byte) 0});
weightScaleProfile.processWeightMeasurement(new byte[]{(byte) 0, (byte) 1, (byte) 0});
weightScaleProfile.processWeightMeasurement(new byte[]{(byte) 0, (byte) 0, (byte) 1});
weightScaleProfile.processWeightMeasurement(new byte[]{(byte) 1, (byte) 0, (byte) 0});
weightScaleProfile.processWeightMeasurement(new byte[]{(byte) 1, (byte) 1, (byte) 0});
weightScaleProfile.processWeightMeasurement(new byte[]{(byte) 1, (byte) 0, (byte) 1});
assertThat(Results.size(), is(6));
assertThat(Results.get(0).getWeightKilogram().floatValue(), is(0.0f));
assertThat(Results.get(1).getWeightKilogram().floatValue(), is(0.005f));
assertThat(Results.get(2).getWeightKilogram().floatValue(), is(1.28f));
assertThat(Results.get(3).getWeightKilogram().floatValue(), is(0.0f));
assertThat(Results.get(4).getWeightKilogram().floatValue(), is(0.0045359237f));
assertThat(Results.get(5).getWeightKilogram().floatValue(), is(1.1611965f));
}
@Test
public void simpleConcatenated() {
WeightScaleProfile<WeightScaleScaleProfileTest> weightScaleProfile = new WeightScaleProfileProxy(this);
weightScaleProfile.processWeightMeasurement(new byte[]{
(byte) 0, (byte) 0, (byte) 0,
(byte) 0, (byte) 1, (byte) 0,
(byte) 0, (byte) 0, (byte) 1,
(byte) 1, (byte) 0, (byte) 0,
(byte) 1, (byte) 1, (byte) 0,
(byte) 1, (byte) 0, (byte) 1,
(byte) 0, (byte) 0xFF, (byte) 0xFF,
(byte) 1, (byte) 0xFF, (byte) 0xFF,
});
assertThat(Results.size(), is(8));
assertThat(Results.get(0).getWeightKilogram().floatValue(), is(0.0f));
assertThat(Results.get(1).getWeightKilogram().floatValue(), is(0.005f));
assertThat(Results.get(2).getWeightKilogram().floatValue(), is(1.28f));
assertThat(Results.get(3).getWeightKilogram().floatValue(), is(0.0f));
assertThat(Results.get(4).getWeightKilogram().floatValue(), is(0.0045359237f));
assertThat(Results.get(5).getWeightKilogram().floatValue(), is(1.1611965f));
assertThat(Results.get(6).getWeightKilogram(), Matchers.nullValue());
assertThat(Results.get(7).getWeightKilogram(), Matchers.nullValue());
}
@Test
public void userId() {
WeightScaleProfile<WeightScaleScaleProfileTest> weightScaleProfile = new WeightScaleProfileProxy(this);
weightScaleProfile.processWeightMeasurement(new byte[]{
(byte) (1 << 2), (byte) 1, (byte) 0, (byte) 1,
(byte) 0, (byte) 2, (byte) 0,
(byte) (1 << 2), (byte) 3, (byte) 0, (byte) 254,
(byte) (1 << 2), (byte) 4, (byte) 0, (byte) 255,
});
assertThat(Results.size(), is(4));
assertThat(Results.get(0).getWeightKilogram().floatValue(), is(0.005f));
assertThat(Results.get(0).getUserId(), is(1));
assertThat(Results.get(1).getWeightKilogram().floatValue(), is(0.010f));
assertThat(Results.get(1).getUserId(), Matchers.nullValue());
assertThat(Results.get(2).getWeightKilogram().floatValue(), is(0.015f));
assertThat(Results.get(2).getUserId(), is(254));
assertThat(Results.get(3).getWeightKilogram().floatValue(), is(0.020f));
assertThat(Results.get(3).getUserId(), Matchers.nullValue());
}
@Test
public void time() {
TimeZone defaultTimeZone = TimeZone.getDefault();
TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles"));
try {
WeightScaleProfile<WeightScaleScaleProfileTest> weightScaleProfile = new WeightScaleProfileProxy(this);
weightScaleProfile.processWeightMeasurement(new byte[]{
(byte) (1 << 1), (byte) 1, (byte) 0,
(byte) 0xE9, (byte) 0x07, // year 2025
(byte) 8, // month 8
(byte) 10, // day 10
(byte) 23, // hour 23
(byte) 59, // minute 59
(byte) 58, // second 58
(byte) 0, (byte) 2, (byte) 0
});
assertThat(Results.size(), is(2));
assertThat(Results.get(0).getWeightKilogram().floatValue(), is(0.005f));
// timestamp was in local time zone Pacific Daylight Time:
// UTC-08:00 with DST (+01:00)
assertThat(Results.get(0).getTime().toString(), is("2025-08-11T06:59:58Z"));
assertThat(Results.get(0).getTime().getEpochSecond(), is(1754895598L));
assertThat(Results.get(1).getWeightKilogram().floatValue(), is(0.010f));
} finally {
TimeZone.setDefault(defaultTimeZone);
}
}
@Test
public void bmi() {
WeightScaleProfile<WeightScaleScaleProfileTest> weightScaleProfile = new WeightScaleProfileProxy(this);
weightScaleProfile.processWeightMeasurement(new byte[]{
(byte) (1 << 3), (byte) 1, (byte) 0,
(byte) 1, (byte) 2, // BMI
(byte) 3, (byte) 4, // height
(byte) ((1 << 3) | 1), (byte) 1, (byte) 0,
(byte) 1, (byte) 2, // BMI
(byte) 3, (byte) 4, // height
(byte) 0, (byte) 2, (byte) 0
});
assertThat(Results.size(), is(3));
assertThat(Results.get(0).getWeightKilogram().floatValue(), is(0.005f));
assertThat(Results.get(0).getBMI(), is(51.3f));
assertThat(Results.get(0).getHeightMeter(), is(1.027f));
assertThat(Results.get(1).getWeightKilogram().floatValue(), is(0.0045359237f));
assertThat(Results.get(1).getBMI(), is(51.3f));
assertThat(Results.get(1).getHeightMeter(), is(2.60858F));
assertThat(Results.get(2).getWeightKilogram().floatValue(), is(0.010f));
}
@Test
public void complex() {
TimeZone defaultTimeZone = TimeZone.getDefault();
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Kathmandu"));
try {
WeightScaleProfile<WeightScaleScaleProfileTest> weightScaleProfile = new WeightScaleProfileProxy(this);
weightScaleProfile.processWeightMeasurement(new byte[]{
(byte) ((1 << 1) | (1 << 2) | (1 << 3)), (byte) 1, (byte) 0,
(byte) 0xE9, (byte) 0x07, // year 2025
(byte) 8, // month 8
(byte) 10, // day 10
(byte) 23, // hour 23
(byte) 59, // minute 59
(byte) 58, // second 58
(byte) 254, // user
(byte) 1, (byte) 2, // BMI
(byte) 3, (byte) 4, // height
(byte) 0, (byte) 2, (byte) 0
});
assertThat(Results.size(), is(2));
assertThat(Results.get(0).getWeightKilogram().floatValue(), is(0.005f));
// timestamp was in local time zone Kathmandu:
// UTC+5:45 no daylight saving time
assertThat(Results.get(0).getTime().toString(), is("2025-08-10T18:14:58Z"));
assertThat(Results.get(0).getTime().getEpochSecond(), is(1754849698L));
assertThat(Results.get(0).getUserId(), is(254));
assertThat(Results.get(0).getBMI(), is(51.3f));
assertThat(Results.get(0).getHeightMeter(), is(1.027f));
assertThat(Results.get(1).getWeightKilogram().floatValue(), is(0.010f));
} finally {
TimeZone.setDefault(defaultTimeZone);
}
}
@Override
public boolean useAutoConnect() {
return false;
}
private class WeightScaleProfileProxy extends WeightScaleProfile<WeightScaleScaleProfileTest> {
WeightScaleProfileProxy(WeightScaleScaleProfileTest support) {
super(support);
}
@Override
protected Intent createIntent(final WeightScaleMeasurement measurement) {
Results.add(measurement);
return null;
}
}
}