diff --git a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java index 422dc6afde..90d78a701f 100644 --- a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java +++ b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java @@ -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; + } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f10ed66e8f..f53136ed61 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -279,6 +279,10 @@ android:name=".devices.ultrahuman.UltrahumanBreathingActivity" android:label="@string/devicetype_ultrahuma_ring_air" android:parentActivityName=".activities.ControlCenterv2" /> + . */ +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(device, session) { + override fun getSampleDao(): AbstractDao { + 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() + } +} \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/generic_scale/GenericWeightScaleAction.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/generic_scale/GenericWeightScaleAction.kt new file mode 100644 index 0000000000..0ad71241d8 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/generic_scale/GenericWeightScaleAction.kt @@ -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 . */ +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) + } +} \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/generic_scale/GenericWeightScaleCoordinator.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/generic_scale/GenericWeightScaleCoordinator.kt new file mode 100644 index 0000000000..ef1021473d --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/generic_scale/GenericWeightScaleCoordinator.kt @@ -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 . */ +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?, Property?> { + return Collections.singletonMap( + session.genericWeightSampleDao, GenericWeightSampleDao.Properties.DeviceId + ) + } + + override fun getCustomActions(): MutableList { + return Collections.singletonList(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? { + 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 { + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/generic_scale/GenericWeightScaleMeasurementActivity.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/generic_scale/GenericWeightScaleMeasurementActivity.kt new file mode 100644 index 0000000000..6eee36c33a --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/generic_scale/GenericWeightScaleMeasurementActivity.kt @@ -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 . */ + +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) + } +} \ No newline at end of file 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 2a51dd372d..9ce64fce44 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.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; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/weightScale/WeightScaleMeasurement.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/weightScale/WeightScaleMeasurement.kt new file mode 100644 index 0000000000..5387d96203 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/weightScale/WeightScaleMeasurement.kt @@ -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 . */ + +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" + } +} \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/weightScale/WeightScaleProfile.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/weightScale/WeightScaleProfile.java new file mode 100644 index 0000000000..15dcea3deb --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/profiles/weightScale/WeightScaleProfile.java @@ -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 . */ +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. +///
    +///
  • org.bluetooth.service.weight_scale / org.bluetooth.characteristic.weight_measurement
  • +///
+/// +/// 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 extends AbstractBleProfile { + /// 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; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/generic_scale/GenericWeightScaleSupport.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/generic_scale/GenericWeightScaleSupport.kt new file mode 100644 index 0000000000..19fa649d25 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/generic_scale/GenericWeightScaleSupport.kt @@ -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 . */ +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 + private var mDeviceInfoProfile: DeviceInfoProfile + private var mWeightScaleProfile: WeightScaleProfile + + + 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(this) + mDeviceInfoProfile.addListener(mListener) + addSupportedProfile(mDeviceInfoProfile) + + addSupportedService(GattService.UUID_SERVICE_BATTERY_SERVICE) + mBatteryInfoProfile = BatteryInfoProfile(this) + mBatteryInfoProfile.addListener(mListener) + addSupportedProfile(mBatteryInfoProfile) + + addSupportedService(GattService.UUID_SERVICE_WEIGHT_SCALE) + mWeightScaleProfile = WeightScaleProfile(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) + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_balance.xml b/app/src/main/res/drawable/ic_balance.xml new file mode 100644 index 0000000000..ec4d5bd914 --- /dev/null +++ b/app/src/main/res/drawable/ic_balance.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/activity_weight_scale_measurement.xml b/app/src/main/res/layout/activity_weight_scale_measurement.xml new file mode 100644 index 0000000000..ea8f4f166e --- /dev/null +++ b/app/src/main/res/layout/activity_weight_scale_measurement.xml @@ -0,0 +1,29 @@ + + + + + +