mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-31 07:44:24 +02:00
Add data export for HC sync verification tooling as debug function
This commit is contained in:
+28
@@ -15,12 +15,15 @@ import nodomain.freeyourgadget.gadgetbridge.database.DBHelper
|
|||||||
import nodomain.freeyourgadget.gadgetbridge.entities.HealthConnectSyncStateDao
|
import nodomain.freeyourgadget.gadgetbridge.entities.HealthConnectSyncStateDao
|
||||||
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils
|
import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils
|
||||||
import nodomain.freeyourgadget.gadgetbridge.util.GB
|
import nodomain.freeyourgadget.gadgetbridge.util.GB
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.GadgetbridgeDataExporter
|
||||||
import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectSyncWorker
|
import nodomain.freeyourgadget.gadgetbridge.util.healthconnect.HealthConnectSyncWorker
|
||||||
import org.slf4j.Logger
|
import org.slf4j.Logger
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Calendar
|
||||||
|
|
||||||
class HealthConnectDebugFragment : AbstractDebugFragment() {
|
class HealthConnectDebugFragment : AbstractDebugFragment() {
|
||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||||
@@ -61,6 +64,30 @@ class HealthConnectDebugFragment : AbstractDebugFragment() {
|
|||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onClick(PREF_DEBUG_EXPORT_GB_DATA) {
|
||||||
|
runOnDebugDevices("Export data for device", forceDialog = true) { gbDevice ->
|
||||||
|
val cal = Calendar.getInstance()
|
||||||
|
cal.add(Calendar.DAY_OF_YEAR, -7)
|
||||||
|
DatePickerDialog(
|
||||||
|
requireActivity(),
|
||||||
|
{ _: DatePicker?, year: Int, month: Int, day: Int ->
|
||||||
|
val startDate = LocalDate.of(year, month + 1, day)
|
||||||
|
val startInstant = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant()
|
||||||
|
val endInstant = Instant.now()
|
||||||
|
try {
|
||||||
|
val file = GadgetbridgeDataExporter.export(requireContext(), gbDevice, startInstant, endInstant)
|
||||||
|
GB.toast("Exported to ${file.name}", Toast.LENGTH_LONG, GB.INFO)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
GB.toast("Export failed: ${e.message}", Toast.LENGTH_LONG, GB.ERROR, e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cal.get(Calendar.YEAR),
|
||||||
|
cal.get(Calendar.MONTH),
|
||||||
|
cal.get(Calendar.DAY_OF_MONTH)
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
reloadSyncStates()
|
reloadSyncStates()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,5 +153,6 @@ class HealthConnectDebugFragment : AbstractDebugFragment() {
|
|||||||
private val LOG: Logger = LoggerFactory.getLogger(HealthConnectDebugFragment::class.java)
|
private val LOG: Logger = LoggerFactory.getLogger(HealthConnectDebugFragment::class.java)
|
||||||
private const val PREF_DEBUG_MANUAL_SYNC_TRIGGER = "pref_debug_manual_sync_trigger"
|
private const val PREF_DEBUG_MANUAL_SYNC_TRIGGER = "pref_debug_manual_sync_trigger"
|
||||||
private const val PREF_DEBUG_RESET_HC_SYNC_STATE = "pref_debug_reset_hc_sync_state"
|
private const val PREF_DEBUG_RESET_HC_SYNC_STATE = "pref_debug_reset_hc_sync_state"
|
||||||
|
private const val PREF_DEBUG_EXPORT_GB_DATA = "pref_debug_export_gb_data"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+220
@@ -0,0 +1,220 @@
|
|||||||
|
package nodomain.freeyourgadget.gadgetbridge.util.healthconnect
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.GBApplication
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.model.*
|
||||||
|
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import java.io.File
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
object GadgetbridgeDataExporter {
|
||||||
|
private val LOG = LoggerFactory.getLogger(GadgetbridgeDataExporter::class.java)
|
||||||
|
private const val TAG = "[HC_SYNC]"
|
||||||
|
|
||||||
|
fun export(context: Context, gbDevice: GBDevice, startInstant: Instant, endInstant: Instant): File {
|
||||||
|
LOG.info("$TAG Export starting for device '{}', range {} to {}", gbDevice.aliasOrName, startInstant, endInstant)
|
||||||
|
|
||||||
|
val result = JSONObject()
|
||||||
|
result.put("export_time", Instant.now().toString())
|
||||||
|
result.put("source", "gadgetbridge")
|
||||||
|
result.put("device", JSONObject().apply {
|
||||||
|
put("name", gbDevice.aliasOrName)
|
||||||
|
put("address", gbDevice.address)
|
||||||
|
put("model", gbDevice.model ?: "")
|
||||||
|
})
|
||||||
|
result.put("time_range", JSONObject().apply {
|
||||||
|
put("start", startInstant.toString())
|
||||||
|
put("end", endInstant.toString())
|
||||||
|
})
|
||||||
|
|
||||||
|
val data = JSONObject()
|
||||||
|
|
||||||
|
val coordinator = gbDevice.deviceCoordinator
|
||||||
|
|
||||||
|
GBApplication.acquireDbReadOnly().use { db ->
|
||||||
|
val tsFrom = startInstant.epochSecond.toInt()
|
||||||
|
val tsTo = endInstant.epochSecond.toInt()
|
||||||
|
val msFrom = startInstant.toEpochMilli()
|
||||||
|
val msTo = endInstant.toEpochMilli()
|
||||||
|
|
||||||
|
val activitySamples = try {
|
||||||
|
val provider = coordinator.getSampleProvider(gbDevice, db.daoSession)
|
||||||
|
provider?.getAllActivitySamples(tsFrom, tsTo) ?: emptyList()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LOG.error("$TAG Failed to get activity samples", e)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
data.put("steps", exportSteps(activitySamples))
|
||||||
|
data.put("heart_rate", exportHeartRate(activitySamples))
|
||||||
|
data.put("sleep", exportSleep(activitySamples))
|
||||||
|
data.put("spo2", exportTimeSamples(coordinator.getSpo2SampleProvider(gbDevice, db.daoSession), msFrom, msTo) { sample ->
|
||||||
|
val spo2 = (sample as? Spo2Sample)?.spo2 ?: return@exportTimeSamples null
|
||||||
|
if (spo2 <= 0 || spo2 > 100) return@exportTimeSamples null
|
||||||
|
JSONObject().apply {
|
||||||
|
put("time", Instant.ofEpochMilli(sample.timestamp).toString())
|
||||||
|
put("percentage", spo2.toDouble())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
data.put("hrv", exportTimeSamples(coordinator.getHrvValueSampleProvider(gbDevice, db.daoSession), msFrom, msTo) { sample ->
|
||||||
|
val hrv = (sample as? HrvValueSample)?.value ?: return@exportTimeSamples null
|
||||||
|
if (hrv <= 0) return@exportTimeSamples null
|
||||||
|
JSONObject().apply {
|
||||||
|
put("time", Instant.ofEpochMilli(sample.timestamp).toString())
|
||||||
|
put("rmssd_ms", hrv.toDouble())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
data.put("respiratory_rate", exportTimeSamples(coordinator.getRespiratoryRateSampleProvider(gbDevice, db.daoSession), msFrom, msTo) { sample ->
|
||||||
|
val rate = (sample as? RespiratoryRateSample)?.respiratoryRate ?: return@exportTimeSamples null
|
||||||
|
if (rate <= 0) return@exportTimeSamples null
|
||||||
|
JSONObject().apply {
|
||||||
|
put("time", Instant.ofEpochMilli(sample.timestamp).toString())
|
||||||
|
put("rate", rate.toDouble())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
data.put("resting_heart_rate", exportTimeSamples(coordinator.getHeartRateRestingSampleProvider(gbDevice, db.daoSession), msFrom, msTo) { sample ->
|
||||||
|
val bpm = (sample as? HeartRateSample)?.heartRate ?: return@exportTimeSamples null
|
||||||
|
if (bpm <= 0) return@exportTimeSamples null
|
||||||
|
JSONObject().apply {
|
||||||
|
put("time", Instant.ofEpochMilli(sample.timestamp).toString())
|
||||||
|
put("bpm", bpm)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
data.put("temperature", exportTimeSamples(coordinator.getTemperatureSampleProvider(gbDevice, db.daoSession), msFrom, msTo) { sample ->
|
||||||
|
val temp = (sample as? TemperatureSample) ?: return@exportTimeSamples null
|
||||||
|
JSONObject().apply {
|
||||||
|
put("time", Instant.ofEpochMilli(sample.timestamp).toString())
|
||||||
|
put("celsius", temp.temperature.toDouble())
|
||||||
|
put("type", temp.temperatureType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
data.put("exercise_sessions", exportWorkouts(db, gbDevice, msFrom, msTo))
|
||||||
|
}
|
||||||
|
|
||||||
|
result.put("data", data)
|
||||||
|
|
||||||
|
val dateFormatter = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")
|
||||||
|
val timestamp = ZonedDateTime.now().format(dateFormatter)
|
||||||
|
val fileName = "gb_export_${gbDevice.aliasOrName.replace(" ", "_")}_$timestamp.json"
|
||||||
|
val dir = FileUtils.getExternalFilesDir()
|
||||||
|
val outFile = File(dir, fileName)
|
||||||
|
outFile.writeText(result.toString(2))
|
||||||
|
|
||||||
|
LOG.info("$TAG Export complete: {}", outFile.absolutePath)
|
||||||
|
return outFile
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun exportSteps(samples: List<ActivitySample>): JSONArray {
|
||||||
|
val arr = JSONArray()
|
||||||
|
for (s in samples) {
|
||||||
|
if (s.steps <= 0) continue
|
||||||
|
arr.put(JSONObject().apply {
|
||||||
|
put("time", Instant.ofEpochSecond(s.timestamp.toLong()).toString())
|
||||||
|
put("steps", s.steps)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun exportHeartRate(samples: List<ActivitySample>): JSONArray {
|
||||||
|
val arr = JSONArray()
|
||||||
|
for (s in samples) {
|
||||||
|
val hr = s.heartRate
|
||||||
|
if (hr <= 0 || hr >= 255) continue
|
||||||
|
arr.put(JSONObject().apply {
|
||||||
|
put("time", Instant.ofEpochSecond(s.timestamp.toLong()).toString())
|
||||||
|
put("bpm", hr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun exportSleep(samples: List<ActivitySample>): JSONArray {
|
||||||
|
val sleepSamples = samples.filter { isSleep(it) }
|
||||||
|
val arr = JSONArray()
|
||||||
|
for (s in sleepSamples) {
|
||||||
|
arr.put(JSONObject().apply {
|
||||||
|
put("time", Instant.ofEpochSecond(s.timestamp.toLong()).toString())
|
||||||
|
put("stage", mapSleepStage(s.kind))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isSleep(sample: ActivitySample): Boolean {
|
||||||
|
return sample.kind == ActivityKind.DEEP_SLEEP ||
|
||||||
|
sample.kind == ActivityKind.LIGHT_SLEEP ||
|
||||||
|
sample.kind == ActivityKind.REM_SLEEP ||
|
||||||
|
sample.kind == ActivityKind.AWAKE_SLEEP
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapSleepStage(kind: ActivityKind): String {
|
||||||
|
return when (kind) {
|
||||||
|
ActivityKind.LIGHT_SLEEP -> "light"
|
||||||
|
ActivityKind.DEEP_SLEEP -> "deep"
|
||||||
|
ActivityKind.REM_SLEEP -> "rem"
|
||||||
|
ActivityKind.AWAKE_SLEEP -> "awake"
|
||||||
|
else -> "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T : TimeSample> exportTimeSamples(
|
||||||
|
provider: TimeSampleProvider<out T>?,
|
||||||
|
msFrom: Long,
|
||||||
|
msTo: Long,
|
||||||
|
converter: (T) -> JSONObject?
|
||||||
|
): JSONArray {
|
||||||
|
val arr = JSONArray()
|
||||||
|
if (provider == null) return arr
|
||||||
|
try {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val samples = provider.getAllSamples(msFrom, msTo) as List<T>
|
||||||
|
for (s in samples) {
|
||||||
|
val json = converter(s)
|
||||||
|
if (json != null) arr.put(json)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LOG.error("$TAG Failed to export time samples", e)
|
||||||
|
}
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun exportWorkouts(db: DBHandler, gbDevice: GBDevice, msFrom: Long, msTo: Long): JSONArray {
|
||||||
|
val arr = JSONArray()
|
||||||
|
try {
|
||||||
|
val deviceEntity = DBHelper.getDevice(gbDevice, db.daoSession) ?: return arr
|
||||||
|
val summaries = db.daoSession.baseActivitySummaryDao.queryBuilder()
|
||||||
|
.where(
|
||||||
|
BaseActivitySummaryDao.Properties.DeviceId.eq(deviceEntity.id),
|
||||||
|
BaseActivitySummaryDao.Properties.StartTime.ge(java.util.Date(msFrom)),
|
||||||
|
BaseActivitySummaryDao.Properties.StartTime.lt(java.util.Date(msTo))
|
||||||
|
)
|
||||||
|
.orderAsc(BaseActivitySummaryDao.Properties.StartTime)
|
||||||
|
.list()
|
||||||
|
|
||||||
|
for (s in summaries) {
|
||||||
|
arr.put(JSONObject().apply {
|
||||||
|
put("start", s.startTime.toInstant().toString())
|
||||||
|
put("end", s.endTime.toInstant().toString())
|
||||||
|
put("activity_kind", s.activityKind)
|
||||||
|
put("name", s.name ?: "")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LOG.error("$TAG Failed to export workouts", e)
|
||||||
|
}
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -16,4 +16,11 @@
|
|||||||
android:summary="Reset Health Connect sync state and allow re-syncing all data"
|
android:summary="Reset Health Connect sync state and allow re-syncing all data"
|
||||||
android:title="Reset HC sync state" />
|
android:title="Reset HC sync state" />
|
||||||
|
|
||||||
|
<Preference
|
||||||
|
android:icon="@drawable/ic_save"
|
||||||
|
android:key="pref_debug_export_gb_data"
|
||||||
|
android:persistent="false"
|
||||||
|
android:summary="Export device data as JSON (for HC sync verification)"
|
||||||
|
android:title="Export GB data as JSON" />
|
||||||
|
|
||||||
</androidx.preference.PreferenceScreen>
|
</androidx.preference.PreferenceScreen>
|
||||||
|
|||||||
Reference in New Issue
Block a user