mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
Health Connect: Drop out-of-range sample values to prevent sync abort
Health Connect enforces hard min/max bounds on each record-type field; a single out-of-range value caused the record constructor to throw and killed the entire batch insert. The trigger was a Garmin Venu 3 producing HRV > 200 ms, but several syncers shared the same gap (no upper bound, only > 0 or no validation at all). Add explicit per-syncer numeric guards matching the bounds enforced by HC's record constructors, drop out-of-range samples, log them with the existing [HC_SYNC] prefix, and continue the sync. Bounds applied (per-record HC enforcement): - HRV: 1..200 ms - VO2Max: 0..100 ml/kg/min - Weight: 0..1000 kg - BloodGlucose: 0..900.91 mg/dL (= 50 mmol/L) -- previously unvalidated - RespiratoryRate: 0..1000 breaths/min - HeartRate / RestingHR: 1..300 / 0..300 bpm (widened from 20..250 to match HC's contract; downstream consumers can apply physiological filters) - SpO2: 0..100 % - Steps: 1..1000000 per record - ActiveCalories: 0..1000000 kcal per record - Distance: 0..1000000 m per record - Skin temperature delta: -30..30 °C - Body temperature: existing physiological clamp 25..45 °C kept (HC has no enforced bound) Add Logger.skipOutOfRange extension in syncers/SyncerLogging.kt and relax HC_SYNC_TAG visibility from private to internal so syncers can share it. HeartRateSync aggregates dropped-sample counts into one summary log line per slice rather than per-sample, to avoid log spam from glitchy firmware. convertSample visibility relaxed from protected to internal in the two abstract base syncers so the new boundary tests can call it directly. 30 new unit tests in SyncerRangeValidationTest cover boundary conditions per syncer.
This commit is contained in:
+1
-1
@@ -495,7 +495,7 @@ class HealthConnectUtils {
|
|||||||
internal const val MAX_SAMPLES_PER_HEART_RATE_RECORD = 1000
|
internal const val MAX_SAMPLES_PER_HEART_RATE_RECORD = 1000
|
||||||
private const val MAX_RETRIES = 5
|
private const val MAX_RETRIES = 5
|
||||||
private const val INITIAL_DELAY_MS = 1000L
|
private const val INITIAL_DELAY_MS = 1000L
|
||||||
private const val HC_SYNC_TAG = "[HC_SYNC]"
|
internal const val HC_SYNC_TAG = "[HC_SYNC]"
|
||||||
|
|
||||||
private fun getSyncTimestampRange(
|
private fun getSyncTimestampRange(
|
||||||
context: Context,
|
context: Context,
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@ internal abstract class AbstractActivitySampleSyncer<TRecord : Record> : Activit
|
|||||||
protected abstract val logger: Logger
|
protected abstract val logger: Logger
|
||||||
protected abstract val recordClass: KClass<TRecord>
|
protected abstract val recordClass: KClass<TRecord>
|
||||||
|
|
||||||
protected abstract fun convertSample(
|
internal abstract fun convertSample(
|
||||||
sample: ActivitySample,
|
sample: ActivitySample,
|
||||||
offset: ZoneOffset,
|
offset: ZoneOffset,
|
||||||
metadata: Metadata,
|
metadata: Metadata,
|
||||||
|
|||||||
+1
-1
@@ -49,7 +49,7 @@ internal abstract class AbstractTimeSampleSyncer<TSample : TimeSample, TRecord :
|
|||||||
daoSession: DaoSession
|
daoSession: DaoSession
|
||||||
): TimeSampleProvider<out TSample>?
|
): TimeSampleProvider<out TSample>?
|
||||||
|
|
||||||
protected abstract fun convertSample(
|
internal abstract fun convertSample(
|
||||||
sample: TSample,
|
sample: TSample,
|
||||||
offset: ZoneOffset,
|
offset: ZoneOffset,
|
||||||
metadata: Metadata,
|
metadata: Metadata,
|
||||||
|
|||||||
+5
@@ -41,6 +41,11 @@ internal object ActiveCaloriesSyncer : AbstractActivitySampleSyncer<ActiveCalori
|
|||||||
if (caloriesInMinute <= 0) {
|
if (caloriesInMinute <= 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
// HC's ActiveCaloriesBurnedRecord caps energy at 1_000_000 kcal (= 1e9 cal).
|
||||||
|
if (caloriesInMinute > 1_000_000_000) {
|
||||||
|
logger.skipOutOfRange(deviceName, "ActiveCalories", "$caloriesInMinute cal", "<= 1000000 kcal per record")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
val endTs = Instant.ofEpochSecond(sample.timestamp.toLong())
|
val endTs = Instant.ofEpochSecond(sample.timestamp.toLong())
|
||||||
val startTs = endTs.minus(1, ChronoUnit.MINUTES)
|
val startTs = endTs.minus(1, ChronoUnit.MINUTES)
|
||||||
|
|||||||
+8
-2
@@ -47,11 +47,17 @@ internal object BloodGlucoseSyncer : AbstractTimeSampleSyncer<GlucoseSample, Blo
|
|||||||
offset: ZoneOffset,
|
offset: ZoneOffset,
|
||||||
metadata: Metadata,
|
metadata: Metadata,
|
||||||
deviceName: String
|
deviceName: String
|
||||||
): BloodGlucoseRecord {
|
): BloodGlucoseRecord? {
|
||||||
|
// HC's BloodGlucoseRecord caps level at 50 mmol/L = 900.91 mg/dL.
|
||||||
|
val mgDl = sample.valueMgDl
|
||||||
|
if (!mgDl.isFinite() || mgDl !in 0.0..900.91) {
|
||||||
|
logger.skipOutOfRange(deviceName, "BloodGlucose", "$mgDl mg/dL", "0..900.91 mg/dL (50 mmol/L)")
|
||||||
|
return null
|
||||||
|
}
|
||||||
return BloodGlucoseRecord(
|
return BloodGlucoseRecord(
|
||||||
time = Instant.ofEpochMilli(sample.timestamp),
|
time = Instant.ofEpochMilli(sample.timestamp),
|
||||||
zoneOffset = offset,
|
zoneOffset = offset,
|
||||||
level = BloodGlucose.milligramsPerDeciliter(sample.valueMgDl),
|
level = BloodGlucose.milligramsPerDeciliter(mgDl),
|
||||||
metadata = metadata
|
metadata = metadata
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -41,6 +41,11 @@ internal object DistanceSyncer : AbstractActivitySampleSyncer<DistanceRecord>()
|
|||||||
if (distanceCm <= 0 || distanceCm == ActivitySample.NOT_MEASURED) {
|
if (distanceCm <= 0 || distanceCm == ActivitySample.NOT_MEASURED) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
// HC's DistanceRecord caps distance at 1_000_000 m (= 1e8 cm).
|
||||||
|
if (distanceCm > 100_000_000) {
|
||||||
|
logger.skipOutOfRange(deviceName, "Distance", "$distanceCm cm", "<= 1000000 m per record")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
val endTs = Instant.ofEpochSecond(sample.timestamp.toLong())
|
val endTs = Instant.ofEpochSecond(sample.timestamp.toLong())
|
||||||
val startTs = endTs.minus(1, ChronoUnit.MINUTES)
|
val startTs = endTs.minus(1, ChronoUnit.MINUTES)
|
||||||
|
|||||||
+16
-2
@@ -52,10 +52,24 @@ internal object HeartRateSyncer : ActivitySampleSyncer {
|
|||||||
return SyncerStatistics(recordType = "HeartRate")
|
return SyncerStatistics(recordType = "HeartRate")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Relevant Input Data Check
|
// 2. Relevant Input Data Check (HC enforces 1..300 bpm)
|
||||||
|
var droppedOutOfRange = 0
|
||||||
val validHRSamples = deviceSamples
|
val validHRSamples = deviceSamples
|
||||||
.filter { it.heartRate in 20..250 }
|
.filter {
|
||||||
|
val inRange = it.heartRate in 1..300
|
||||||
|
// 0 means "not measured" - common, don't count as out-of-range
|
||||||
|
if (!inRange && it.heartRate != 0) {
|
||||||
|
droppedOutOfRange++
|
||||||
|
}
|
||||||
|
inRange
|
||||||
|
}
|
||||||
.sortedBy { it.timestamp }
|
.sortedBy { it.timestamp }
|
||||||
|
if (droppedOutOfRange > 0) {
|
||||||
|
LOG.info(
|
||||||
|
"${HealthConnectUtils.HC_SYNC_TAG} Dropped {} out-of-range HeartRate sample(s) for device '{}' in slice {} to {} (HC requires 1..300 bpm).",
|
||||||
|
droppedOutOfRange, deviceName, sliceStartBoundary, sliceEndBoundary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (validHRSamples.isEmpty()) {
|
if (validHRSamples.isEmpty()) {
|
||||||
LOG.info("No valid heart rate samples found for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
|
LOG.info("No valid heart rate samples found for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
|
||||||
|
|||||||
+4
-7
@@ -45,19 +45,16 @@ internal object HrvSyncer : AbstractTimeSampleSyncer<HrvValueSample, HeartRateVa
|
|||||||
metadata: Metadata,
|
metadata: Metadata,
|
||||||
deviceName: String
|
deviceName: String
|
||||||
): HeartRateVariabilityRmssdRecord? {
|
): HeartRateVariabilityRmssdRecord? {
|
||||||
if (sample.value <= 0) {
|
val value = sample.value.toDouble()
|
||||||
logger.debug(
|
if (value !in 1.0..200.0 || !value.isFinite()) {
|
||||||
"Skipping HRV value sample for device '{}' due to non-positive value: {}.",
|
logger.skipOutOfRange(deviceName, "HRV", value, "1..200 ms")
|
||||||
deviceName,
|
|
||||||
sample.value
|
|
||||||
)
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return HeartRateVariabilityRmssdRecord(
|
return HeartRateVariabilityRmssdRecord(
|
||||||
time = Instant.ofEpochMilli(sample.timestamp),
|
time = Instant.ofEpochMilli(sample.timestamp),
|
||||||
zoneOffset = offset,
|
zoneOffset = offset,
|
||||||
heartRateVariabilityMillis = sample.value.toDouble(),
|
heartRateVariabilityMillis = value,
|
||||||
metadata = metadata
|
metadata = metadata
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -45,14 +45,16 @@ internal object RespiratoryRateSyncer : AbstractTimeSampleSyncer<RespiratoryRate
|
|||||||
metadata: Metadata,
|
metadata: Metadata,
|
||||||
deviceName: String
|
deviceName: String
|
||||||
): RespiratoryRateRecord? {
|
): RespiratoryRateRecord? {
|
||||||
if (sample.respiratoryRate <= 0) {
|
val rate = sample.respiratoryRate.toDouble()
|
||||||
|
if (rate !in 0.0..1000.0 || !rate.isFinite()) {
|
||||||
|
logger.skipOutOfRange(deviceName, "RespiratoryRate", rate, "0..1000 breaths/min")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return RespiratoryRateRecord(
|
return RespiratoryRateRecord(
|
||||||
time = Instant.ofEpochMilli(sample.timestamp),
|
time = Instant.ofEpochMilli(sample.timestamp),
|
||||||
zoneOffset = offset,
|
zoneOffset = offset,
|
||||||
rate = sample.respiratoryRate.toDouble(),
|
rate = rate,
|
||||||
metadata = metadata
|
metadata = metadata
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -45,7 +45,8 @@ internal object RestingHeartRateSyncer : AbstractTimeSampleSyncer<HeartRateSampl
|
|||||||
metadata: Metadata,
|
metadata: Metadata,
|
||||||
deviceName: String
|
deviceName: String
|
||||||
): RestingHeartRateRecord? {
|
): RestingHeartRateRecord? {
|
||||||
if (sample.heartRate !in 20..250) {
|
if (sample.heartRate !in 0..300) {
|
||||||
|
logger.skipOutOfRange(deviceName, "RestingHeartRate", sample.heartRate, "0..300 bpm")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-6
@@ -48,12 +48,8 @@ internal object Spo2Syncer : AbstractTimeSampleSyncer<Spo2Sample, OxygenSaturati
|
|||||||
): OxygenSaturationRecord? {
|
): OxygenSaturationRecord? {
|
||||||
val spo2AsDouble = sample.spo2.toDouble()
|
val spo2AsDouble = sample.spo2.toDouble()
|
||||||
|
|
||||||
if (spo2AsDouble <= 0 || spo2AsDouble > 100) {
|
if (spo2AsDouble !in 0.0..100.0 || !spo2AsDouble.isFinite()) {
|
||||||
logger.debug(
|
logger.skipOutOfRange(deviceName, "SpO2", spo2AsDouble, "0..100 %")
|
||||||
"Skipping SpO2 sample for device '{}' with invalid value ({}). Valid range: >0 and <=100.",
|
|
||||||
deviceName,
|
|
||||||
spo2AsDouble
|
|
||||||
)
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -37,7 +37,12 @@ internal object StepsSyncer : AbstractActivitySampleSyncer<StepsRecord>() {
|
|||||||
deviceName: String
|
deviceName: String
|
||||||
): StepsRecord? {
|
): StepsRecord? {
|
||||||
val stepsInMinute = sample.steps.toLong()
|
val stepsInMinute = sample.steps.toLong()
|
||||||
if (stepsInMinute <= 0) {
|
// <= 0 means "no steps in that minute" - common, drop silently.
|
||||||
|
if (stepsInMinute <= 0L) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (stepsInMinute > 1_000_000L) {
|
||||||
|
logger.skipOutOfRange(deviceName, "Steps", stepsInMinute, "1..1000000 per record")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
/* Copyright (C) 2025 Gideon Zenz
|
||||||
|
|
||||||
|
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.util.healthconnect.syncers
|
||||||
|
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectUtils
|
||||||
|
import org.slf4j.Logger
|
||||||
|
|
||||||
|
internal fun Logger.skipOutOfRange(
|
||||||
|
deviceName: String,
|
||||||
|
fieldLabel: String,
|
||||||
|
value: Any?,
|
||||||
|
rangeDescription: String
|
||||||
|
) {
|
||||||
|
info(
|
||||||
|
"${HealthConnectUtils.HC_SYNC_TAG} Skipping {} for device '{}': value {} out of range ({}).",
|
||||||
|
fieldLabel, deviceName, value, rangeDescription
|
||||||
|
)
|
||||||
|
}
|
||||||
+8
-7
@@ -94,13 +94,8 @@ internal object TemperatureSyncer : HealthConnectSyncer {
|
|||||||
LOG.info("Processing ${bodySamples.size} body temperature samples for '$deviceName'.")
|
LOG.info("Processing ${bodySamples.size} body temperature samples for '$deviceName'.")
|
||||||
for (sample in bodySamples) {
|
for (sample in bodySamples) {
|
||||||
val sampleTemp = sample.temperature.toDouble()
|
val sampleTemp = sample.temperature.toDouble()
|
||||||
if (sampleTemp !in MIN_PLAUSIBLE_BODY_TEMP_C..MAX_PLAUSIBLE_BODY_TEMP_C) {
|
if (sampleTemp !in MIN_PLAUSIBLE_BODY_TEMP_C..MAX_PLAUSIBLE_BODY_TEMP_C || !sampleTemp.isFinite()) {
|
||||||
LOG.debug(
|
LOG.skipOutOfRange(deviceName, "BodyTemperature", "${sample.temperature}°C", "$MIN_PLAUSIBLE_BODY_TEMP_C..$MAX_PLAUSIBLE_BODY_TEMP_C °C")
|
||||||
"Skipping Body Temperature sample for device '{}' at {} due to implausible value: {}°C.",
|
|
||||||
deviceName,
|
|
||||||
Instant.ofEpochMilli(sample.timestamp),
|
|
||||||
sample.temperature
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val timestamp = Instant.ofEpochMilli(sample.timestamp)
|
val timestamp = Instant.ofEpochMilli(sample.timestamp)
|
||||||
@@ -165,6 +160,12 @@ internal object TemperatureSyncer : HealthConnectSyncer {
|
|||||||
currentTempC - baselineForCurrentRecord
|
currentTempC - baselineForCurrentRecord
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (deltaValue !in -30.0..30.0 || !deltaValue.isFinite()) {
|
||||||
|
LOG.skipOutOfRange(deviceName, "SkinTemperatureDelta", deltaValue, "-30..30 °C")
|
||||||
|
previousTempC = currentTempC
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
deltas.add(
|
deltas.add(
|
||||||
SkinTemperatureRecord.Delta(
|
SkinTemperatureRecord.Delta(
|
||||||
time = Instant.ofEpochMilli(sample.timestamp),
|
time = Instant.ofEpochMilli(sample.timestamp),
|
||||||
|
|||||||
+4
-3
@@ -45,15 +45,16 @@ internal object Vo2MaxSyncer : AbstractTimeSampleSyncer<Vo2MaxSample, Vo2MaxReco
|
|||||||
metadata: Metadata,
|
metadata: Metadata,
|
||||||
deviceName: String
|
deviceName: String
|
||||||
): Vo2MaxRecord? {
|
): Vo2MaxRecord? {
|
||||||
if (sample.value <= 0) {
|
val value = sample.value.toDouble()
|
||||||
logger.trace("Skipping VO2Max sample for device '$deviceName' due to invalid value: ${sample.value}.")
|
if (value !in 0.0..100.0 || !value.isFinite()) {
|
||||||
|
logger.skipOutOfRange(deviceName, "VO2Max", value, "0..100 ml/kg/min")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return Vo2MaxRecord(
|
return Vo2MaxRecord(
|
||||||
time = Instant.ofEpochMilli(sample.timestamp),
|
time = Instant.ofEpochMilli(sample.timestamp),
|
||||||
zoneOffset = offset,
|
zoneOffset = offset,
|
||||||
vo2MillilitersPerMinuteKilogram = sample.value.toDouble(),
|
vo2MillilitersPerMinuteKilogram = value,
|
||||||
measurementMethod = Vo2MaxRecord.MEASUREMENT_METHOD_OTHER,
|
measurementMethod = Vo2MaxRecord.MEASUREMENT_METHOD_OTHER,
|
||||||
metadata = metadata
|
metadata = metadata
|
||||||
)
|
)
|
||||||
|
|||||||
+4
-8
@@ -46,21 +46,17 @@ internal object WeightSyncer : AbstractTimeSampleSyncer<WeightSample, WeightReco
|
|||||||
metadata: Metadata,
|
metadata: Metadata,
|
||||||
deviceName: String
|
deviceName: String
|
||||||
): WeightRecord? {
|
): WeightRecord? {
|
||||||
val weightInKg = sample.getWeightKg()
|
val weightInKg = sample.getWeightKg().toDouble()
|
||||||
|
|
||||||
if (weightInKg <= 0) {
|
if (weightInKg !in 0.0..1000.0 || !weightInKg.isFinite()) {
|
||||||
logger.debug(
|
logger.skipOutOfRange(deviceName, "Weight", weightInKg, "0..1000 kg")
|
||||||
"Skipping Weight sample for device '{}' due to invalid weight: {}kg (must be > 0).",
|
|
||||||
deviceName,
|
|
||||||
weightInKg
|
|
||||||
)
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return WeightRecord(
|
return WeightRecord(
|
||||||
time = Instant.ofEpochMilli(sample.timestamp),
|
time = Instant.ofEpochMilli(sample.timestamp),
|
||||||
zoneOffset = offset,
|
zoneOffset = offset,
|
||||||
weight = Mass.kilograms(weightInKg.toDouble()),
|
weight = Mass.kilograms(weightInKg),
|
||||||
metadata = metadata
|
metadata = metadata
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+237
@@ -0,0 +1,237 @@
|
|||||||
|
package nodomain.freeyourgadget.gadgetbridge.util.healthconnect.syncers
|
||||||
|
|
||||||
|
import androidx.health.connect.client.records.metadata.Device
|
||||||
|
import androidx.health.connect.client.records.metadata.Metadata
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.entities.GlucoseSample
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.model.HeartRateSample
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.model.HrvValueSample
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.model.RespiratoryRateSample
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.model.Vo2MaxSample
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.model.WeightSample
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
|
class SyncerRangeValidationTest {
|
||||||
|
|
||||||
|
private val offset: ZoneOffset = ZoneOffset.UTC
|
||||||
|
private val device = "test-device"
|
||||||
|
private val metadata: Metadata = Metadata.unknownRecordingMethod(
|
||||||
|
Device(type = Device.TYPE_WATCH, manufacturer = "test", model = "test")
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- HRV ---
|
||||||
|
|
||||||
|
private fun hrvSample(value: Int, ts: Long = 1_700_000_000_000L): HrvValueSample =
|
||||||
|
object : HrvValueSample {
|
||||||
|
override fun getTimestamp(): Long = ts
|
||||||
|
override fun getValue(): Int = value
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hrv_lowerBoundary_accepted() {
|
||||||
|
assertNotNull(HrvSyncer.convertSample(hrvSample(1), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hrv_upperBoundary_accepted() {
|
||||||
|
assertNotNull(HrvSyncer.convertSample(hrvSample(200), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hrv_belowLower_dropped() {
|
||||||
|
assertNull(HrvSyncer.convertSample(hrvSample(0), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hrv_aboveUpper_dropped() {
|
||||||
|
// The actual #6190 value
|
||||||
|
assertNull(HrvSyncer.convertSample(hrvSample(211), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hrv_negative_dropped() {
|
||||||
|
assertNull(HrvSyncer.convertSample(hrvSample(-1), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- VO2 max ---
|
||||||
|
|
||||||
|
private fun vo2Sample(value: Float, ts: Long = 1_700_000_000_000L): Vo2MaxSample =
|
||||||
|
object : Vo2MaxSample {
|
||||||
|
override fun getTimestamp(): Long = ts
|
||||||
|
override fun getValue(): Float = value
|
||||||
|
override fun getType(): Vo2MaxSample.Type = Vo2MaxSample.Type.ANY
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun vo2_lowerBoundary_accepted() {
|
||||||
|
assertNotNull(Vo2MaxSyncer.convertSample(vo2Sample(0.0f), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun vo2_upperBoundary_accepted() {
|
||||||
|
assertNotNull(Vo2MaxSyncer.convertSample(vo2Sample(100.0f), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun vo2_aboveUpper_dropped() {
|
||||||
|
assertNull(Vo2MaxSyncer.convertSample(vo2Sample(101.0f), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun vo2_negative_dropped() {
|
||||||
|
assertNull(Vo2MaxSyncer.convertSample(vo2Sample(-0.5f), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun vo2_nan_dropped() {
|
||||||
|
assertNull(Vo2MaxSyncer.convertSample(vo2Sample(Float.NaN), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Weight ---
|
||||||
|
|
||||||
|
private fun weightSample(kg: Float, ts: Long = 1_700_000_000_000L): WeightSample =
|
||||||
|
object : WeightSample {
|
||||||
|
override fun getTimestamp(): Long = ts
|
||||||
|
override fun getWeightKg(): Float = kg
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun weight_lowerBoundary_accepted() {
|
||||||
|
// HC's WeightRecord.weight uses requireNotLess(weight, 0 kg), so 0.0 is at the bound and accepted.
|
||||||
|
assertNotNull(WeightSyncer.convertSample(weightSample(0.0f), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun weight_upperBoundary_accepted() {
|
||||||
|
assertNotNull(WeightSyncer.convertSample(weightSample(1000.0f), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun weight_aboveUpper_dropped() {
|
||||||
|
assertNull(WeightSyncer.convertSample(weightSample(1001.0f), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun weight_negative_dropped() {
|
||||||
|
assertNull(WeightSyncer.convertSample(weightSample(-0.1f), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Respiratory rate ---
|
||||||
|
|
||||||
|
private fun respSample(rate: Float, ts: Long = 1_700_000_000_000L): RespiratoryRateSample =
|
||||||
|
object : RespiratoryRateSample {
|
||||||
|
override fun getTimestamp(): Long = ts
|
||||||
|
override fun getRespiratoryRate(): Float = rate
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resp_lowerBoundary_accepted() {
|
||||||
|
assertNotNull(RespiratoryRateSyncer.convertSample(respSample(0.0f), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resp_upperBoundary_accepted() {
|
||||||
|
assertNotNull(RespiratoryRateSyncer.convertSample(respSample(1000.0f), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resp_aboveUpper_dropped() {
|
||||||
|
assertNull(RespiratoryRateSyncer.convertSample(respSample(1001.0f), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resp_negative_dropped() {
|
||||||
|
assertNull(RespiratoryRateSyncer.convertSample(respSample(-1.0f), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SpO2 ---
|
||||||
|
|
||||||
|
private fun spo2Sample(spo2: Int, ts: Long = 1_700_000_000_000L): Spo2Sample =
|
||||||
|
object : Spo2Sample {
|
||||||
|
override fun getTimestamp(): Long = ts
|
||||||
|
override fun getSpo2(): Int = spo2
|
||||||
|
override fun getType(): Spo2Sample.Type = Spo2Sample.Type.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun spo2_lowerBoundary_accepted() {
|
||||||
|
assertNotNull(Spo2Syncer.convertSample(spo2Sample(0), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun spo2_upperBoundary_accepted() {
|
||||||
|
assertNotNull(Spo2Syncer.convertSample(spo2Sample(100), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun spo2_aboveUpper_dropped() {
|
||||||
|
assertNull(Spo2Syncer.convertSample(spo2Sample(101), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun spo2_negative_dropped() {
|
||||||
|
assertNull(Spo2Syncer.convertSample(spo2Sample(-1), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Resting HR ---
|
||||||
|
|
||||||
|
private fun hrSample(bpm: Int, ts: Long = 1_700_000_000_000L): HeartRateSample =
|
||||||
|
object : HeartRateSample {
|
||||||
|
override fun getTimestamp(): Long = ts
|
||||||
|
override fun getHeartRate(): Int = bpm
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun restingHr_lowerBoundary_accepted() {
|
||||||
|
assertNotNull(RestingHeartRateSyncer.convertSample(hrSample(0), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun restingHr_upperBoundary_accepted() {
|
||||||
|
assertNotNull(RestingHeartRateSyncer.convertSample(hrSample(300), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun restingHr_aboveUpper_dropped() {
|
||||||
|
assertNull(RestingHeartRateSyncer.convertSample(hrSample(301), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun restingHr_negative_dropped() {
|
||||||
|
assertNull(RestingHeartRateSyncer.convertSample(hrSample(-1), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Blood glucose ---
|
||||||
|
|
||||||
|
private fun glucoseSample(mgDl: Double, ts: Long = 1_700_000_000_000L): GlucoseSample {
|
||||||
|
val s = GlucoseSample()
|
||||||
|
s.timestamp = ts
|
||||||
|
s.valueMgDl = mgDl
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bloodGlucose_lowerBoundary_accepted() {
|
||||||
|
assertNotNull(BloodGlucoseSyncer.convertSample(glucoseSample(0.0), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bloodGlucose_upperBoundary_accepted() {
|
||||||
|
// 50 mmol/L ~= 900.91 mg/dL
|
||||||
|
assertNotNull(BloodGlucoseSyncer.convertSample(glucoseSample(900.0), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bloodGlucose_aboveUpper_dropped() {
|
||||||
|
// > 50 mmol/L
|
||||||
|
assertNull(BloodGlucoseSyncer.convertSample(glucoseSample(950.0), offset, metadata, device))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bloodGlucose_negative_dropped() {
|
||||||
|
assertNull(BloodGlucoseSyncer.convertSample(glucoseSample(-1.0), offset, metadata, device))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user