mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
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:
committed by
José Rebelo
parent
7eaa5bb76b
commit
5b1a4b237b
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
+1
@@ -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";
|
||||
|
||||
+7
-2
@@ -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);
|
||||
|
||||
|
||||
+43
@@ -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()
|
||||
}
|
||||
}
|
||||
+44
@@ -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)
|
||||
}
|
||||
}
|
||||
+113
@@ -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
|
||||
}
|
||||
}
|
||||
+177
@@ -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;
|
||||
|
||||
+40
@@ -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"
|
||||
}
|
||||
}
|
||||
+184
@@ -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;
|
||||
}
|
||||
}
|
||||
+137
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
+233
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user