mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
Health Connect: Sync respiratory rate
This commit is contained in:
@@ -126,6 +126,7 @@
|
||||
<uses-permission android:name="android.permission.health.WRITE_SPEED" />
|
||||
<uses-permission android:name="android.permission.health.WRITE_POWER" />
|
||||
<uses-permission android:name="android.permission.health.WRITE_EXERCISE_ROUTE" />
|
||||
<uses-permission android:name="android.permission.health.WRITE_RESPIRATORY_RATE" />
|
||||
<uses-sdk tools:overrideLibrary="androidx.health.connect.client"/>
|
||||
|
||||
<uses-feature
|
||||
|
||||
+3
@@ -66,6 +66,7 @@ object HealthConnectPermissionManager {
|
||||
HealthPermission.getWritePermission(ElevationGainedRecord::class),
|
||||
HealthPermission.getWritePermission(PowerRecord::class),
|
||||
HealthPermission.getWritePermission(SpeedRecord::class),
|
||||
HealthPermission.getWritePermission(RespiratoryRateRecord::class),
|
||||
PERMISSION_WRITE_EXERCISE_ROUTE
|
||||
)
|
||||
|
||||
@@ -77,6 +78,7 @@ object HealthConnectPermissionManager {
|
||||
WEIGHT,
|
||||
SPO2,
|
||||
TEMPERATURE,
|
||||
RESPIRATORY_RATE,
|
||||
WORKOUTS
|
||||
}
|
||||
|
||||
@@ -95,6 +97,7 @@ object HealthConnectPermissionManager {
|
||||
HealthConnectDataType.HRV -> setOf(HealthPermission.getWritePermission(HeartRateVariabilityRmssdRecord::class))
|
||||
HealthConnectDataType.WEIGHT -> setOf(HealthPermission.getWritePermission(WeightRecord::class))
|
||||
HealthConnectDataType.SPO2 -> setOf(HealthPermission.getWritePermission(OxygenSaturationRecord::class))
|
||||
HealthConnectDataType.RESPIRATORY_RATE -> setOf(HealthPermission.getWritePermission(RespiratoryRateRecord::class))
|
||||
HealthConnectDataType.TEMPERATURE -> setOf(
|
||||
HealthPermission.getWritePermission(BodyTemperatureRecord::class),
|
||||
HealthPermission.getWritePermission(SkinTemperatureRecord::class)
|
||||
|
||||
+5
@@ -596,6 +596,10 @@ class HealthConnectUtils {
|
||||
healthConnectClient, gbDevice, metadata, offset,
|
||||
currentSliceStartTs, currentSliceEndTs, grantedPermissions
|
||||
))
|
||||
HealthConnectPermissionManager.HealthConnectDataType.RESPIRATORY_RATE -> sliceStats.add(RespiratoryRateSyncer.sync(
|
||||
healthConnectClient, gbDevice, metadata, offset,
|
||||
currentSliceStartTs, currentSliceEndTs, grantedPermissions
|
||||
))
|
||||
HealthConnectPermissionManager.HealthConnectDataType.WORKOUTS -> {
|
||||
// Sync explicitly recorded workouts from BaseActivitySummary
|
||||
val coordinator = gbDevice.deviceCoordinator
|
||||
@@ -691,6 +695,7 @@ class HealthConnectUtils {
|
||||
HealthConnectPermissionManager.HealthConnectDataType.ACTIVITY, HealthConnectPermissionManager.HealthConnectDataType.SLEEP -> coordinator.getSampleProvider(device, db.daoSession)
|
||||
HealthConnectPermissionManager.HealthConnectDataType.VO2MAX -> coordinator.getVo2MaxSampleProvider(device, db.daoSession)
|
||||
HealthConnectPermissionManager.HealthConnectDataType.HRV -> coordinator.getHrvValueSampleProvider(device, db.daoSession)
|
||||
HealthConnectPermissionManager.HealthConnectDataType.RESPIRATORY_RATE -> coordinator.getRespiratoryRateSampleProvider(device, db.daoSession)
|
||||
HealthConnectPermissionManager.HealthConnectDataType.WEIGHT -> coordinator.getWeightSampleProvider(device, db.daoSession)
|
||||
// For SpO2 and Temperature, there might be a specific provider or fallback to general sample provider
|
||||
HealthConnectPermissionManager.HealthConnectDataType.SPO2 -> coordinator.getSpo2SampleProvider(device, db.daoSession) // Potentially add fallback if needed: ?: coordinator.getSampleProvider(device, db.daoSession)
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
/* Copyright (C) 2026 José Rebelo
|
||||
|
||||
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 androidx.health.connect.client.HealthConnectClient
|
||||
import androidx.health.connect.client.permission.HealthPermission
|
||||
import androidx.health.connect.client.records.Record
|
||||
import androidx.health.connect.client.records.metadata.Metadata
|
||||
import nodomain.freeyourgadget.gadgetbridge.GBApplication
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession
|
||||
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.TimeSample
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectUtils
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.SyncException
|
||||
import org.slf4j.Logger
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* A base class for synchronizing generic Gadgetbridge TimeSample samples to Health Connect.
|
||||
* This class provides common logic for fetching samples and converting them to records.
|
||||
*
|
||||
* Note: We need recordClass as an abstract property due to JVM type erasure - generic type
|
||||
* information is not available at runtime, so we can't automatically derive TRecord.
|
||||
*/
|
||||
internal abstract class AbstractTimeSampleSyncer<TSample : TimeSample, TRecord : Record> : HealthConnectSyncer {
|
||||
protected abstract val logger: Logger
|
||||
protected abstract val recordClass: KClass<TRecord>
|
||||
|
||||
protected abstract fun getSampleProvider(
|
||||
gbDevice: GBDevice,
|
||||
daoSession: DaoSession
|
||||
): TimeSampleProvider<out TSample>?
|
||||
|
||||
protected abstract fun convertSample(
|
||||
sample: TSample,
|
||||
offset: ZoneOffset,
|
||||
metadata: Metadata,
|
||||
deviceName: String
|
||||
): TRecord?
|
||||
|
||||
override suspend fun sync(
|
||||
healthConnectClient: HealthConnectClient,
|
||||
gbDevice: GBDevice,
|
||||
metadata: Metadata,
|
||||
offset: ZoneOffset,
|
||||
sliceStartBoundary: Instant,
|
||||
sliceEndBoundary: Instant,
|
||||
grantedPermissions: Set<String>
|
||||
): SyncerStatistics {
|
||||
val deviceName = gbDevice.aliasOrName
|
||||
val recordTypeName = recordClass.simpleName ?: "Unknown"
|
||||
|
||||
// Check permissions
|
||||
if (HealthPermission.getWritePermission(recordClass) !in grantedPermissions) {
|
||||
logger.info("Skipping $recordTypeName sync for device '$deviceName'; $recordTypeName permission not granted.")
|
||||
return SyncerStatistics(recordType = recordTypeName)
|
||||
}
|
||||
|
||||
// Fetch samples
|
||||
val samples: List<TSample> = try {
|
||||
GBApplication.acquireDB().use { dbInstance ->
|
||||
val provider = getSampleProvider(gbDevice, dbInstance.daoSession)
|
||||
if (provider == null) {
|
||||
logger.info("$recordTypeName sample provider not available for device '$deviceName'.")
|
||||
return SyncerStatistics(recordType = recordTypeName)
|
||||
}
|
||||
provider.getAllSamples(sliceStartBoundary.toEpochMilli(), sliceEndBoundary.toEpochMilli())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
throw SyncException(
|
||||
"Error fetching $recordTypeName samples for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.",
|
||||
e
|
||||
)
|
||||
}
|
||||
|
||||
if (samples.isEmpty()) {
|
||||
logger.info("No $recordTypeName samples found by provider for device '$deviceName' in slice $sliceStartBoundary to $sliceEndBoundary.")
|
||||
return SyncerStatistics(recordType = recordTypeName)
|
||||
}
|
||||
|
||||
logger.info("Processing ${samples.size} $recordTypeName samples for device '$deviceName'.")
|
||||
|
||||
// Convert samples to records
|
||||
var skippedCount = 0
|
||||
|
||||
val recordsToInsert = samples.filter {
|
||||
val timestamp = Instant.ofEpochMilli(it.timestamp)
|
||||
if (timestamp.isBefore(sliceStartBoundary) || !timestamp.isBefore(sliceEndBoundary)) {
|
||||
logger.debug(
|
||||
"Skipping sample for at {} as it's outside the slice {} - {}.",
|
||||
timestamp,
|
||||
sliceStartBoundary,
|
||||
sliceEndBoundary
|
||||
)
|
||||
return@filter false
|
||||
}
|
||||
return@filter true
|
||||
}.mapNotNull {
|
||||
convertSample(
|
||||
it,
|
||||
offset,
|
||||
metadata,
|
||||
deviceName
|
||||
) ?: run {
|
||||
skippedCount++
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
if (recordsToInsert.isEmpty()) {
|
||||
logger.info("No valid $recordTypeName records to insert for device '$deviceName' in slice $sliceStartBoundary to $sliceEndBoundary after processing samples.")
|
||||
return SyncerStatistics(recordsSkipped = skippedCount, recordType = recordTypeName)
|
||||
}
|
||||
|
||||
// Insert records
|
||||
logger.info("Attempting to insert ${recordsToInsert.size} $recordTypeName(s) for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
|
||||
HealthConnectUtils.insertRecords(recordsToInsert, healthConnectClient)
|
||||
|
||||
logger.info("Successfully inserted ${recordsToInsert.size} $recordTypeName(s) for device '$deviceName' for slice $sliceStartBoundary to $sliceEndBoundary.")
|
||||
return SyncerStatistics(
|
||||
recordsSynced = recordsToInsert.size,
|
||||
recordsSkipped = skippedCount,
|
||||
recordType = recordTypeName
|
||||
)
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/* Copyright (C) 2026 José Rebelo
|
||||
|
||||
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 androidx.health.connect.client.records.RespiratoryRateRecord
|
||||
import androidx.health.connect.client.records.metadata.Metadata
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession
|
||||
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.RespiratoryRateSample
|
||||
import org.slf4j.Logger
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
internal object RespiratoryRateSyncer : AbstractTimeSampleSyncer<RespiratoryRateSample, RespiratoryRateRecord>() {
|
||||
override val logger: Logger = LoggerFactory.getLogger(RespiratoryRateSyncer::class.java)
|
||||
override val recordClass: KClass<RespiratoryRateRecord> = RespiratoryRateRecord::class
|
||||
|
||||
override fun getSampleProvider(
|
||||
gbDevice: GBDevice,
|
||||
daoSession: DaoSession
|
||||
): TimeSampleProvider<out RespiratoryRateSample>? {
|
||||
return gbDevice.deviceCoordinator.getRespiratoryRateSampleProvider(gbDevice, daoSession)
|
||||
}
|
||||
|
||||
override fun convertSample(
|
||||
sample: RespiratoryRateSample,
|
||||
offset: ZoneOffset,
|
||||
metadata: Metadata,
|
||||
deviceName: String
|
||||
): RespiratoryRateRecord? {
|
||||
if (sample.respiratoryRate <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return RespiratoryRateRecord(
|
||||
time = Instant.ofEpochMilli(sample.timestamp),
|
||||
zoneOffset = offset,
|
||||
rate = sample.respiratoryRate.toDouble(),
|
||||
metadata = metadata
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user