From 295962f2bcead2d90fd69bbf38f0c00e0bb3c432 Mon Sep 17 00:00:00 2001 From: this-Aditya Date: Fri, 1 May 2026 14:00:33 +0530 Subject: [PATCH 01/12] New library for google health api --- google-health-library/build.gradle | 42 ++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 google-health-library/build.gradle diff --git a/google-health-library/build.gradle b/google-health-library/build.gradle new file mode 100644 index 00000000..785104e7 --- /dev/null +++ b/google-health-library/build.gradle @@ -0,0 +1,42 @@ + +group = 'org.radarbase' +version = '0.0.1' + +apply plugin: 'maven-publish' + +repositories { + mavenCentral() +} + +dependencies { + implementation libs.kotlin.stdlib + + implementation libs.okhttp + + implementation libs.radar.schemas.commons + + implementation libs.jackson.annotations + + implementation libs.jackson.databind + + implementation libs.avro + + implementation libs.jackson.datatype.jsr310 + + testImplementation libs.kotlin.test + + testImplementation libs.kotlin.test.junit +} + +project.afterEvaluate { + publishing { + publications { + library(MavenPublication) { + setGroupId "$group" + setArtifactId "google-health-library" + version "$version" + from components.java + } + } + } +} From 2f64a7bd8c64bba68eb69b7eed14eb0d2ec16d5e Mon Sep 17 00:00:00 2001 From: this-Aditya Date: Fri, 1 May 2026 14:00:52 +0530 Subject: [PATCH 02/12] Include new library as a project sub-module --- settings.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/settings.gradle.kts b/settings.gradle.kts index 64f23944..91ea6aed 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -3,6 +3,7 @@ include(":kafka-connect-fitbit-source") include(":kafka-connect-rest-source") include(":kafka-connect-oura-source") include(":oura-library") +include(":google-health-library") pluginManagement { repositories { From 26828e7ddb06f3a474be069aa464c96f42a993bf Mon Sep 17 00:00:00 2001 From: this-Aditya Date: Fri, 1 May 2026 14:01:39 +0530 Subject: [PATCH 03/12] Base user model class --- .../org/radarbase/googlehealth/user/User.kt | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/User.kt diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/User.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/User.kt new file mode 100644 index 00000000..a26be109 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/User.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.user + +import org.radarcns.kafka.ObservationKey +import java.time.Instant + +interface User { + val id: String + val projectId: String + val userId: String + val sourceId: String + val externalId: String? + val startDate: Instant + val endDate: Instant? + val createdAt: Instant + val humanReadableUserId: String? + val serviceUserId: String + val version: String? + val isAuthorized: Boolean + val observationKey: ObservationKey + val versionedId: String +} From 3c50b799beabafe53c6a5cca13c3fc80398e32f6 Mon Sep 17 00:00:00 2001 From: this-Aditya Date: Fri, 1 May 2026 14:03:15 +0530 Subject: [PATCH 04/12] Google health user data classes added --- .../googlehealth/user/GoogleHealthUser.kt | 41 +++++++++++++++++++ .../googlehealth/user/GoogleHealthUsers.kt | 23 +++++++++++ 2 files changed, 64 insertions(+) create mode 100644 google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/GoogleHealthUser.kt create mode 100644 google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/GoogleHealthUsers.kt diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/GoogleHealthUser.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/GoogleHealthUser.kt new file mode 100644 index 00000000..0c2a19da --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/GoogleHealthUser.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.user + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import org.radarcns.kafka.ObservationKey +import java.time.Instant + +@JsonIgnoreProperties(ignoreUnknown = true) +data class GoogleHealthUser( + @param:JsonProperty("id") override val id: String, + @param:JsonProperty("createdAt") override val createdAt: Instant, + @param:JsonProperty("projectId") override val projectId: String, + @param:JsonProperty("userId") override val userId: String, + @param:JsonProperty("humanReadableUserId") override val humanReadableUserId: String?, + @param:JsonProperty("sourceId") override val sourceId: String, + @param:JsonProperty("externalId") override val externalId: String?, + @param:JsonProperty("isAuthorized") override val isAuthorized: Boolean, + @param:JsonProperty("startDate") override val startDate: Instant, + @param:JsonProperty("endDate") override val endDate: Instant?, + @param:JsonProperty("version") override val version: String? = null, + @param:JsonProperty("serviceUserId") override val serviceUserId: String, +) : User { + override val observationKey: ObservationKey = ObservationKey(projectId, userId, sourceId) + override val versionedId: String = "$id${version?.let { "#$it" } ?: ""}" +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/GoogleHealthUsers.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/GoogleHealthUsers.kt new file mode 100644 index 00000000..c7e42487 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/GoogleHealthUsers.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.user + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty + +@JsonIgnoreProperties(ignoreUnknown = true) +data class GoogleHealthUsers(@param:JsonProperty("users") val users: List) From ff84725d0aeb92b08c3ccac34f3d5a976833db90 Mon Sep 17 00:00:00 2001 From: this-Aditya Date: Fri, 1 May 2026 14:03:59 +0530 Subject: [PATCH 05/12] Model class for oauth access tokens --- .../googlehealth/user/RestOauth2AccessToken.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/RestOauth2AccessToken.kt diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/RestOauth2AccessToken.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/RestOauth2AccessToken.kt new file mode 100644 index 00000000..6c67df85 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/RestOauth2AccessToken.kt @@ -0,0 +1,11 @@ +package org.radarbase.googlehealth.user + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import java.time.Instant + +@JsonIgnoreProperties(ignoreUnknown = true) +data class RestOauth2AccessToken( + @param:JsonProperty("accessToken") val accessToken: String, + @param:JsonProperty("expiresAt") val expiresAt: Instant? = null, +) From 9a97ecce59601623bc8106a294aeb6f9d34015b1 Mon Sep 17 00:00:00 2001 From: this-Aditya Date: Fri, 1 May 2026 14:04:31 +0530 Subject: [PATCH 06/12] User repositories contract added --- .../user/GoogleHealthUserRepository.kt | 30 +++++++++++++ .../googlehealth/user/UserRepository.kt | 45 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/GoogleHealthUserRepository.kt create mode 100644 google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/UserRepository.kt diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/GoogleHealthUserRepository.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/GoogleHealthUserRepository.kt new file mode 100644 index 00000000..2ccb4fca --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/GoogleHealthUserRepository.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.user + +import java.io.IOException + +abstract class GoogleHealthUserRepository : UserRepository { + + @Throws(NoSuchElementException::class, IOException::class) + abstract override fun findByExternalId(externalId: String): User + + @Throws(IOException::class) + abstract fun getOAuth2AccessToken(user: User): String + + abstract fun deregisterUser(serviceUserId: String) +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/UserRepository.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/UserRepository.kt new file mode 100644 index 00000000..a0014570 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/user/UserRepository.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2018 The Hyve + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package org.radarbase.googlehealth.user + +import org.radarbase.googlehealth.exception.UserNotAuthorizedException +import java.io.IOException + +/** User repository for users. */ +interface UserRepository { + @Throws(IOException::class) + operator fun get(key: String): User? + + @Throws(IOException::class) + fun stream(): Sequence + + @Throws(IOException::class, UserNotAuthorizedException::class) + fun getAccessToken(user: User): String + + @Throws(IOException::class, UserNotAuthorizedException::class) + fun getRefreshToken(user: User): String + + @Throws(NoSuchElementException::class, IOException::class) + fun findByExternalId(externalId: String): User = stream() + .firstOrNull { it.serviceUserId == externalId } + ?: throw NoSuchElementException("User not found in the User repository") + + fun hasPendingUpdates(): Boolean + + @Throws(IOException::class) + fun applyPendingUpdates() +} From 1673b9aad064dceabac912d08c8757c3dc568dca Mon Sep 17 00:00:00 2001 From: this-Aditya Date: Fri, 1 May 2026 14:05:30 +0530 Subject: [PATCH 07/12] Data classes matching the google subscription endpoint response --- .../googlehealth/model/GoogleHealthPing.kt | 24 +++++++++++++++++++ .../googlehealth/model/PingInterval.kt | 24 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 google-health-library/src/main/kotlin/org/radarbase/googlehealth/model/GoogleHealthPing.kt create mode 100644 google-health-library/src/main/kotlin/org/radarbase/googlehealth/model/PingInterval.kt diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/model/GoogleHealthPing.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/model/GoogleHealthPing.kt new file mode 100644 index 00000000..dcc2638c --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/model/GoogleHealthPing.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.model + +data class GoogleHealthPing( + val healthUserId: String, + val operation: String, + val dataType: String, + val intervals: List, +) diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/model/PingInterval.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/model/PingInterval.kt new file mode 100644 index 00000000..161c4723 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/model/PingInterval.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.model + +import java.time.Instant + +data class PingInterval( + val physicalStartTime: Instant, + val physicalEndTime: Instant, +) From 653c68722a67c668a6e3ab3e5837a2af7feba251 Mon Sep 17 00:00:00 2001 From: this-Aditya Date: Fri, 1 May 2026 14:05:55 +0530 Subject: [PATCH 08/12] Exception classes --- .../TransientGoogleHealthException.kt | 23 +++++++++++++++++++ .../exception/UserNotAuthorizedException.kt | 7 ++++++ 2 files changed, 30 insertions(+) create mode 100644 google-health-library/src/main/kotlin/org/radarbase/googlehealth/exception/TransientGoogleHealthException.kt create mode 100644 google-health-library/src/main/kotlin/org/radarbase/googlehealth/exception/UserNotAuthorizedException.kt diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/exception/TransientGoogleHealthException.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/exception/TransientGoogleHealthException.kt new file mode 100644 index 00000000..bf1dc037 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/exception/TransientGoogleHealthException.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.exception + +/** + * Thrown when a retryable Google Health API error (429 or 5xx) exhausts its retry budget. + * Redis cursor past a chunk that never completed, the next scheduled iteration will retry. + */ +class TransientGoogleHealthException(message: String) : RuntimeException(message) diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/exception/UserNotAuthorizedException.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/exception/UserNotAuthorizedException.kt new file mode 100644 index 00000000..e998be6c --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/exception/UserNotAuthorizedException.kt @@ -0,0 +1,7 @@ +package org.radarbase.googlehealth.exception + +import org.radarbase.googlehealth.user.User + +class UserNotAuthorizedException(message: String) : Exception(message) { + constructor(user: User) : this("User ${user.id} is not authorized") +} From 0c24f3d5f64ce3794a71fae0f19e723c4f87db49 Mon Sep 17 00:00:00 2001 From: this-Aditya Date: Fri, 1 May 2026 14:06:44 +0530 Subject: [PATCH 09/12] Utility classes for avro converters and builders --- .../googlehealth/converter/AvroConverter.kt | 33 ++++++++++++ .../googlehealth/util/AvroBuilders.kt | 50 +++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/AvroConverter.kt create mode 100644 google-health-library/src/main/kotlin/org/radarbase/googlehealth/util/AvroBuilders.kt diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/AvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/AvroConverter.kt new file mode 100644 index 00000000..7c5ed6f6 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/AvroConverter.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import java.io.IOException + +interface AvroConverter { + + val topic: String + + @Throws(IOException::class) + fun convert( + tree: JsonNode, + user: User, + ): List> +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/util/AvroBuilders.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/util/AvroBuilders.kt new file mode 100644 index 00000000..49884887 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/util/AvroBuilders.kt @@ -0,0 +1,50 @@ +package org.radarbase.googlehealth.util + +import org.radarcns.connector.fitbit.FitbitActivityHeartRate +import org.radarcns.connector.fitbit.FitbitActivityLogRecord +import org.radarcns.connector.fitbit.FitbitBreathingRate +import org.radarcns.connector.fitbit.FitbitIntradayCalories +import org.radarcns.connector.fitbit.FitbitIntradayHeartRate +import org.radarcns.connector.fitbit.FitbitIntradayHeartRateVariability +import org.radarcns.connector.fitbit.FitbitIntradaySpo2 +import org.radarcns.connector.fitbit.FitbitIntradaySteps +import org.radarcns.connector.fitbit.FitbitRestingHeartRate +import org.radarcns.connector.fitbit.FitbitSkinTemperature +import org.radarcns.connector.fitbit.FitbitSleepClassic +import org.radarcns.connector.fitbit.FitbitSleepStage + +inline fun intradaySteps(block: FitbitIntradaySteps.Builder.() -> Unit): FitbitIntradaySteps = + FitbitIntradaySteps.newBuilder().apply(block).build() + +inline fun intradayHeartRate(block: FitbitIntradayHeartRate.Builder.() -> Unit): FitbitIntradayHeartRate = + FitbitIntradayHeartRate.newBuilder().apply(block).build() + +inline fun intradayHeartRateVariability(block: FitbitIntradayHeartRateVariability.Builder.() -> Unit): FitbitIntradayHeartRateVariability = + FitbitIntradayHeartRateVariability.newBuilder().apply(block).build() + +inline fun intradaySpo2(block: FitbitIntradaySpo2.Builder.() -> Unit): FitbitIntradaySpo2 = + FitbitIntradaySpo2.newBuilder().apply(block).build() + +inline fun restingHeartRate(block: FitbitRestingHeartRate.Builder.() -> Unit): FitbitRestingHeartRate = + FitbitRestingHeartRate.newBuilder().apply(block).build() + +inline fun breathingRate(block: FitbitBreathingRate.Builder.() -> Unit): FitbitBreathingRate = + FitbitBreathingRate.newBuilder().apply(block).build() + +inline fun skinTemperature(block: FitbitSkinTemperature.Builder.() -> Unit): FitbitSkinTemperature = + FitbitSkinTemperature.newBuilder().apply(block).build() + +inline fun sleepClassic(block: FitbitSleepClassic.Builder.() -> Unit): FitbitSleepClassic = + FitbitSleepClassic.newBuilder().apply(block).build() + +inline fun sleepStage(block: FitbitSleepStage.Builder.() -> Unit): FitbitSleepStage = + FitbitSleepStage.newBuilder().apply(block).build() + +inline fun activityLogRecord(block: FitbitActivityLogRecord.Builder.() -> Unit): FitbitActivityLogRecord = + FitbitActivityLogRecord.newBuilder().apply(block).build() + +inline fun activityHeartRate(block: FitbitActivityHeartRate.Builder.() -> Unit): FitbitActivityHeartRate = + FitbitActivityHeartRate.newBuilder().apply(block).build() + +inline fun intradayCalories(block: FitbitIntradayCalories.Builder.() -> Unit): FitbitIntradayCalories = + FitbitIntradayCalories.newBuilder().apply(block).build() From b47c0d985e1df4a2f5ca47683b599d45c0a7c7c5 Mon Sep 17 00:00:00 2001 From: this-Aditya Date: Fri, 1 May 2026 14:07:35 +0530 Subject: [PATCH 10/12] Base google health avro converter classes to be implemented by all google health converters --- .../converter/GoogleHealthAvroConverter.kt | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthAvroConverter.kt diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthAvroConverter.kt new file mode 100644 index 00000000..290657dc --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/GoogleHealthAvroConverter.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneOffset + +abstract class GoogleHealthAvroConverter(override val topic: String) : AvroConverter { + + abstract fun convertDataPoint( + point: JsonNode, + user: User, + ): List> + + override fun convert(tree: JsonNode, user: User): List> { + val points = tree["dataPoints"] ?: tree["rollupDataPoints"] ?: return emptyList() + if (!points.isArray) return emptyList() + return points.flatMap { convertDataPoint(it, user) } + } + + companion object { + fun parseInterval(node: JsonNode): Pair? { + val interval = node["interval"] ?: return null + val start = runCatching { + Instant.parse(interval["startTime"]?.asText() ?: return null) + }.getOrNull() ?: return null + val end = runCatching { + Instant.parse(interval["endTime"]?.asText() ?: return null) + }.getOrNull() ?: return null + return start to end + } + + fun parseSampleTime(node: JsonNode): Instant? { + val sample = node["sampleTime"] ?: return null + val text = sample["physicalTime"]?.asText()?.takeIf { it.isNotEmpty() } ?: return null + return runCatching { Instant.parse(text) }.getOrNull() + } + + fun parseDate(node: JsonNode, utcOffsetSeconds: Int = 0): Instant? { + val dateNode = node["date"] ?: return null + val year = dateNode["year"]?.asInt() ?: return null + val month = dateNode["month"]?.asInt() ?: return null + val day = dateNode["day"]?.asInt() ?: return null + val local = runCatching { LocalDate.of(year, month, day) }.getOrNull() ?: return null + val timeNode = node["time"] + val dateTime = if (timeNode != null && !timeNode.isNull) { + LocalDateTime.of( + local, + LocalTime.of( + timeNode["hours"]?.asInt() ?: 0, + timeNode["minutes"]?.asInt() ?: 0, + timeNode["seconds"]?.asInt() ?: 0, + timeNode["nanos"]?.asInt() ?: 0, + ), + ) + } else { + local.atStartOfDay() + } + return dateTime.toInstant(ZoneOffset.ofTotalSeconds(utcOffsetSeconds)) + } + + fun parseUtcOffsetSeconds(durationText: String?): Int { + if (durationText.isNullOrEmpty()) return 0 + val trimmed = durationText.trim().removeSuffix("s") + val seconds = trimmed.toLongOrNull() ?: return 0 + return seconds.toInt() + } + + fun epochSeconds(instant: Instant): Double = instant.toEpochMilli() / 1000.0 + + fun nowEpochSeconds(): Double = Instant.now().toEpochMilli() / 1000.0 + } +} From 540c919b29bf25241b78f107d32db871d7aee5ed Mon Sep 17 00:00:00 2001 From: this-Aditya Date: Fri, 1 May 2026 14:07:58 +0530 Subject: [PATCH 11/12] Specific data type converter classes added --- ...stingHeartRateGoogleHealthAvroConverter.kt | 46 +++++++++++ ...ureDerivationsGoogleHealthAvroConverter.kt | 43 +++++++++++ .../ExerciseGoogleHealthAvroConverter.kt | 73 ++++++++++++++++++ .../HeartRateGoogleHealthAvroConverter.kt | 40 ++++++++++ ...ateVariabilityGoogleHealthAvroConverter.kt | 48 ++++++++++++ ...ygenSaturationGoogleHealthAvroConverter.kt | 40 ++++++++++ ...teSleepSummaryGoogleHealthAvroConverter.kt | 50 ++++++++++++ .../SleepClassicGoogleHealthAvroConverter.kt | 76 +++++++++++++++++++ .../SleepStageGoogleHealthAvroConverter.kt | 72 ++++++++++++++++++ .../StepsGoogleHealthAvroConverter.kt | 40 ++++++++++ .../TotalCaloriesGoogleHealthAvroConverter.kt | 63 +++++++++++++++ 11 files changed, 591 insertions(+) create mode 100644 google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/DailyRestingHeartRateGoogleHealthAvroConverter.kt create mode 100644 google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/DailySleepTemperatureDerivationsGoogleHealthAvroConverter.kt create mode 100644 google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/ExerciseGoogleHealthAvroConverter.kt create mode 100644 google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/HeartRateGoogleHealthAvroConverter.kt create mode 100644 google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/HeartRateVariabilityGoogleHealthAvroConverter.kt create mode 100644 google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/OxygenSaturationGoogleHealthAvroConverter.kt create mode 100644 google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/RespiratoryRateSleepSummaryGoogleHealthAvroConverter.kt create mode 100644 google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SleepClassicGoogleHealthAvroConverter.kt create mode 100644 google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SleepStageGoogleHealthAvroConverter.kt create mode 100644 google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/StepsGoogleHealthAvroConverter.kt create mode 100644 google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/TotalCaloriesGoogleHealthAvroConverter.kt diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/DailyRestingHeartRateGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/DailyRestingHeartRateGoogleHealthAvroConverter.kt new file mode 100644 index 00000000..5b4e98f3 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/DailyRestingHeartRateGoogleHealthAvroConverter.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import org.radarbase.googlehealth.util.restingHeartRate + +class DailyRestingHeartRateGoogleHealthAvroConverter(topic: String) : + GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val data = point["dailyRestingHeartRate"] ?: return emptyList() + val dateNode = data["date"] ?: return emptyList() + val bpm = data["beatsPerMinute"]?.asInt() ?: return emptyList() + val isoDate = String.format( + "%04d-%02d-%02d", + dateNode["year"].asInt(), + dateNode["month"].asInt(), + dateNode["day"].asInt(), + ) + val record = restingHeartRate { + date = isoDate + timeReceived = nowEpochSeconds() + restingHeartRate = bpm + } + return listOf(user.observationKey to record) + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/DailySleepTemperatureDerivationsGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/DailySleepTemperatureDerivationsGoogleHealthAvroConverter.kt new file mode 100644 index 00000000..f4c070ba --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/DailySleepTemperatureDerivationsGoogleHealthAvroConverter.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import org.radarbase.googlehealth.util.skinTemperature +import org.radarcns.connector.fitbit.FitbitSkinTemperatureLogType + +class DailySleepTemperatureDerivationsGoogleHealthAvroConverter(topic: String) : + GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val data = point["dailySleepTemperatureDerivations"] ?: return emptyList() + val nightly = data["nightlyTemperatureCelsius"]?.takeIf { it.isNumber }?.floatValue() ?: return emptyList() + val baseline = data["baselineTemperatureCelsius"]?.takeIf { it.isNumber }?.floatValue() ?: return emptyList() + val time = parseDate(data) ?: return emptyList() + val record = skinTemperature { + this.time = epochSeconds(time) + timeReceived = nowEpochSeconds() + relativeTemperature = nightly - baseline + logType = FitbitSkinTemperatureLogType.UNKNOWN + } + return listOf(user.observationKey to record) + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/ExerciseGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/ExerciseGoogleHealthAvroConverter.kt new file mode 100644 index 00000000..d5a62552 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/ExerciseGoogleHealthAvroConverter.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import org.radarbase.googlehealth.util.activityHeartRate +import org.radarbase.googlehealth.util.activityLogRecord + +class ExerciseGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val data = point["exercise"] ?: return emptyList() + val (start, end) = parseInterval(data) ?: return emptyList() + val offsetSeconds = parseUtcOffsetSeconds( + data["interval"]?.get("startUtcOffset")?.asText(), + ) + val durationSec = (end.epochSecond - start.epochSecond).toFloat().coerceAtLeast(0.0f) + val metrics = data["metricsSummary"] + val distanceKm = metrics?.get("distanceMillimeters")?.takeIf { !it.isNull } + ?.asDouble()?.let { it.toFloat() / 1_000_000f } + val caloriesKcal = metrics?.get("caloriesKcal")?.takeIf { !it.isNull }?.asDouble() + val energyKj = caloriesKcal?.let { (it * KCAL_TO_KJ).toFloat() } + val stepCount = metrics?.get("steps")?.takeIf { !it.isNull }?.asInt() + val avgHr = metrics?.get("averageHeartRateBeatsPerMinute")?.takeIf { !it.isNull }?.asInt() + val avgHeartRate = avgHr?.let { activityHeartRate { mean = it } } + val exerciseType = data["exerciseType"]?.asText() + val activityId = (point["name"]?.asText() ?: exerciseType ?: "") + .hashCode().toLong() + val record = activityLogRecord { + time = epochSeconds(start) + timeReceived = nowEpochSeconds() + timeZoneOffset = offsetSeconds + timeLastModified = epochSeconds(end) + duration = durationSec + durationActive = durationSec + id = activityId + name = data["displayName"]?.asText() ?: exerciseType + logType = point["dataSource"]?.get("recordingMethod")?.asText() + type = null + source = null + manualDataEntry = null + energy = energyKj + levels = null + heartRate = avgHeartRate + steps = stepCount + distance = distanceKm + speed = null + } + return listOf(user.observationKey to record) + } + + companion object { + private const val KCAL_TO_KJ = 4.1868 + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/HeartRateGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/HeartRateGoogleHealthAvroConverter.kt new file mode 100644 index 00000000..509f8eaa --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/HeartRateGoogleHealthAvroConverter.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import org.radarbase.googlehealth.util.intradayHeartRate + +class HeartRateGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val data = point["heartRate"] ?: return emptyList() + val time = parseSampleTime(data) ?: return emptyList() + val bpm = data["beatsPerMinute"]?.asInt() ?: return emptyList() + val record = intradayHeartRate { + this.time = epochSeconds(time) + timeReceived = nowEpochSeconds() + timeInterval = 1 + heartRate = bpm + } + return listOf(user.observationKey to record) + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/HeartRateVariabilityGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/HeartRateVariabilityGoogleHealthAvroConverter.kt new file mode 100644 index 00000000..4ec61852 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/HeartRateVariabilityGoogleHealthAvroConverter.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import org.radarbase.googlehealth.util.intradayHeartRateVariability + +class HeartRateVariabilityGoogleHealthAvroConverter(topic: String) : + GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val data = point["heartRateVariability"] ?: return emptyList() + val time = parseSampleTime(data) ?: return emptyList() + val rmssd = data["rootMeanSquareOfSuccessiveDifferencesMilliseconds"]?.floatValue() + ?: return emptyList() + val record = intradayHeartRateVariability { + this.time = epochSeconds(time) + timeReceived = nowEpochSeconds() + this.rmssd = rmssd + coverage = UNAVAILABLE + highFrequency = UNAVAILABLE + lowFrequency = UNAVAILABLE + } + return listOf(user.observationKey to record) + } + + companion object { + private const val UNAVAILABLE = 0.0f + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/OxygenSaturationGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/OxygenSaturationGoogleHealthAvroConverter.kt new file mode 100644 index 00000000..6e864218 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/OxygenSaturationGoogleHealthAvroConverter.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import org.radarbase.googlehealth.util.intradaySpo2 + +class OxygenSaturationGoogleHealthAvroConverter(topic: String) : + GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val data = point["oxygenSaturation"] ?: return emptyList() + val time = parseSampleTime(data) ?: return emptyList() + val pct = data["percentage"]?.floatValue() ?: return emptyList() + val record = intradaySpo2 { + this.time = epochSeconds(time) + timeReceived = nowEpochSeconds() + spo2 = pct + } + return listOf(user.observationKey to record) + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/RespiratoryRateSleepSummaryGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/RespiratoryRateSleepSummaryGoogleHealthAvroConverter.kt new file mode 100644 index 00000000..e5243b2b --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/RespiratoryRateSleepSummaryGoogleHealthAvroConverter.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import org.radarbase.googlehealth.util.breathingRate + +class RespiratoryRateSleepSummaryGoogleHealthAvroConverter(topic: String) : + GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val data = point["respiratoryRateSleepSummary"] ?: return emptyList() + val time = parseSampleTime(data) ?: return emptyList() + val deep = data["deepSleepStats"]?.get("breathsPerMinute")?.floatValue() ?: UNAVAILABLE + val full = data["fullSleepStats"]?.get("breathsPerMinute")?.floatValue() ?: UNAVAILABLE + val light = data["lightSleepStats"]?.get("breathsPerMinute")?.floatValue() ?: UNAVAILABLE + val rem = data["remSleepStats"]?.get("breathsPerMinute")?.floatValue() ?: UNAVAILABLE + val record = breathingRate { + this.time = epochSeconds(time) + timeReceived = nowEpochSeconds() + lightSleep = light + deepSleep = deep + remSleep = rem + fullSleep = full + } + return listOf(user.observationKey to record) + } + + companion object { + private const val UNAVAILABLE = 0.0f + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SleepClassicGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SleepClassicGoogleHealthAvroConverter.kt new file mode 100644 index 00000000..d7a78d24 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SleepClassicGoogleHealthAvroConverter.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import org.radarbase.googlehealth.util.sleepClassic +import org.radarcns.connector.fitbit.FitbitSleepClassicLevel +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +class SleepClassicGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val data = point["sleep"] ?: return emptyList() + val stages = data["stages"]?.takeIf { it.isArray } ?: return emptyList() + val timeReceived = nowEpochSeconds() + // Route by stage-type family. This converter only emits records for CLASSIC + // stages (ASLEEP/RESTLESS). Also includes AWAKE because AWAKE appears in + // both Google's STAGES and CLASSIC enum families — we map it here ONLY when + // the session has at least one ASLEEP or RESTLESS stage (otherwise a STAGES + // session's AWAKE would incorrectly route to classic). See SleepStageGoogleHealthAvroConverter. + val isClassicSession = stages.any { s -> + s["type"]?.asText() in CLASSIC_ONLY + } + if (!isClassicSession) return emptyList() + return stages.mapNotNull { stage -> + val stageType = stage["type"]?.asText() ?: return@mapNotNull null + if (stageType !in CLASSIC_FAMILY) return@mapNotNull null + val start = stage["startTime"]?.asText() + ?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return@mapNotNull null + val end = stage["endTime"]?.asText() + ?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return@mapNotNull null + val record = sleepClassic { + dateTime = LOCAL_FMT.format(LocalDateTime.ofInstant(start, ZoneOffset.UTC)) + this.timeReceived = timeReceived + duration = (end.epochSecond - start.epochSecond).toInt().coerceAtLeast(0) + level = mapLevel(stageType) + efficiency = null + } + user.observationKey to record + } + } + + private fun mapLevel(text: String?): FitbitSleepClassicLevel = when (text) { + "ASLEEP" -> FitbitSleepClassicLevel.ASLEEP + "RESTLESS" -> FitbitSleepClassicLevel.RESTLESS + "AWAKE" -> FitbitSleepClassicLevel.AWAKE + else -> FitbitSleepClassicLevel.UNKNOWN + } + + companion object { + private val LOCAL_FMT: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME + private val CLASSIC_ONLY = setOf("ASLEEP", "RESTLESS") + private val CLASSIC_FAMILY = setOf("ASLEEP", "RESTLESS", "AWAKE") + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SleepStageGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SleepStageGoogleHealthAvroConverter.kt new file mode 100644 index 00000000..0b30da43 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/SleepStageGoogleHealthAvroConverter.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import org.radarbase.googlehealth.util.sleepStage +import org.radarcns.connector.fitbit.FitbitSleepStageLevel +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +class SleepStageGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val data = point["sleep"] ?: return emptyList() + val stages = data["stages"]?.takeIf { it.isArray } ?: return emptyList() + val timeReceived = nowEpochSeconds() + // Route by stage-type family rather than relying on a top-level `type` field, + // whose presence in list/reconcile responses is unverified — the v4 discovery + // schema does NOT list `type` on the Sleep resource. This converter only emits + // records for the STAGES family (DEEP/LIGHT/REM/AWAKE). Classic stages + // (ASLEEP/RESTLESS) are handled by SleepClassicGoogleHealthAvroConverter. + return stages.mapNotNull { stage -> + val stageType = stage["type"]?.asText() ?: return@mapNotNull null + if (stageType !in STAGES_FAMILY) return@mapNotNull null + val start = stage["startTime"]?.asText() + ?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return@mapNotNull null + val end = stage["endTime"]?.asText() + ?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return@mapNotNull null + val record = sleepStage { + dateTime = LOCAL_FMT.format(LocalDateTime.ofInstant(start, ZoneOffset.UTC)) + this.timeReceived = timeReceived + duration = (end.epochSecond - start.epochSecond).toInt().coerceAtLeast(0) + level = mapLevel(stageType) + efficiency = null + } + user.observationKey to record + } + } + + private fun mapLevel(text: String?): FitbitSleepStageLevel = when (text) { + "DEEP" -> FitbitSleepStageLevel.DEEP + "LIGHT" -> FitbitSleepStageLevel.LIGHT + "REM" -> FitbitSleepStageLevel.REM + "AWAKE" -> FitbitSleepStageLevel.AWAKE + else -> FitbitSleepStageLevel.UNKNOWN + } + + companion object { + private val LOCAL_FMT: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME + private val STAGES_FAMILY = setOf("DEEP", "LIGHT", "REM", "AWAKE") + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/StepsGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/StepsGoogleHealthAvroConverter.kt new file mode 100644 index 00000000..97c5a185 --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/StepsGoogleHealthAvroConverter.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import org.radarbase.googlehealth.util.intradaySteps + +class StepsGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val data = point["steps"] ?: return emptyList() + val (start, end) = parseInterval(data) ?: return emptyList() + val count = data["count"]?.asInt() ?: return emptyList() + val record = intradaySteps { + time = epochSeconds(start) + timeReceived = nowEpochSeconds() + timeInterval = (end.epochSecond - start.epochSecond).toInt().coerceAtLeast(0) + steps = count + } + return listOf(user.observationKey to record) + } +} diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/TotalCaloriesGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/TotalCaloriesGoogleHealthAvroConverter.kt new file mode 100644 index 00000000..9e9f182d --- /dev/null +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/TotalCaloriesGoogleHealthAvroConverter.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2026 King's College London + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.radarbase.googlehealth.converter + +import com.fasterxml.jackson.databind.JsonNode +import org.apache.avro.specific.SpecificRecord +import org.radarbase.googlehealth.user.User +import org.radarbase.googlehealth.util.intradayCalories +import java.time.Instant + +class TotalCaloriesGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroConverter(topic) { + override fun convertDataPoint( + point: JsonNode, + user: User, + ): List> { + val start = readInstant(point, "startTime") + ?: point["totalCalories"]?.get("interval")?.get("startTime")?.asText()?.let(Instant::parse) + ?: return emptyList() + val end = readInstant(point, "endTime") + ?: point["totalCalories"]?.get("interval")?.get("endTime")?.asText()?.let(Instant::parse) + ?: return emptyList() + val kilocalories = extractKilocalories(point) ?: return emptyList() + val record = intradayCalories { + time = epochSeconds(start) + timeReceived = nowEpochSeconds() + timeInterval = (end.epochSecond - start.epochSecond).toInt().coerceAtLeast(0) + calories = kilocalories + level = 0 + mets = 0.0 + } + return listOf(user.observationKey to record) + } + + private fun readInstant(node: JsonNode, field: String): Instant? = + node[field]?.asText()?.takeIf { it.isNotEmpty() }?.let(Instant::parse) + + /** RollupDataPoint's `totalCalories.kcalSum` is the real field (confirmed live 2026-04-20); + * `kilocalories` is kept as a fallback for forward-compatibility in case the API changes. */ + private fun extractKilocalories(point: JsonNode): Double? { + point["totalCalories"]?.get("kcalSum")?.takeIf { !it.isNull } + ?.let { return it.doubleValue() } + point["totalCalories"]?.get("kilocalories")?.takeIf { !it.isNull } + ?.let { return it.doubleValue() } + point["value"]?.get("totalCalories")?.get("kcalSum")?.takeIf { !it.isNull } + ?.let { return it.doubleValue() } + point["kilocalories"]?.takeIf { !it.isNull }?.let { return it.doubleValue() } + return null + } +} From c5ec008c6d41660d412dac15c51e78bb843c07f6 Mon Sep 17 00:00:00 2001 From: this-Aditya Date: Sun, 3 May 2026 16:25:03 +0530 Subject: [PATCH 12/12] Simplifying the total calories conversion --- .../TotalCaloriesGoogleHealthAvroConverter.kt | 26 +++---------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/TotalCaloriesGoogleHealthAvroConverter.kt b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/TotalCaloriesGoogleHealthAvroConverter.kt index 9e9f182d..ee7dcb2f 100644 --- a/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/TotalCaloriesGoogleHealthAvroConverter.kt +++ b/google-health-library/src/main/kotlin/org/radarbase/googlehealth/converter/TotalCaloriesGoogleHealthAvroConverter.kt @@ -27,13 +27,9 @@ class TotalCaloriesGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroCo point: JsonNode, user: User, ): List> { - val start = readInstant(point, "startTime") - ?: point["totalCalories"]?.get("interval")?.get("startTime")?.asText()?.let(Instant::parse) - ?: return emptyList() - val end = readInstant(point, "endTime") - ?: point["totalCalories"]?.get("interval")?.get("endTime")?.asText()?.let(Instant::parse) - ?: return emptyList() - val kilocalories = extractKilocalories(point) ?: return emptyList() + val start = point["startTime"]?.asText()?.let(Instant::parse) ?: return emptyList() + val end = point["endTime"]?.asText()?.let(Instant::parse) ?: return emptyList() + val kilocalories = point["totalCalories"]?.get("kcalSum")?.doubleValue() ?: return emptyList() val record = intradayCalories { time = epochSeconds(start) timeReceived = nowEpochSeconds() @@ -44,20 +40,4 @@ class TotalCaloriesGoogleHealthAvroConverter(topic: String) : GoogleHealthAvroCo } return listOf(user.observationKey to record) } - - private fun readInstant(node: JsonNode, field: String): Instant? = - node[field]?.asText()?.takeIf { it.isNotEmpty() }?.let(Instant::parse) - - /** RollupDataPoint's `totalCalories.kcalSum` is the real field (confirmed live 2026-04-20); - * `kilocalories` is kept as a fallback for forward-compatibility in case the API changes. */ - private fun extractKilocalories(point: JsonNode): Double? { - point["totalCalories"]?.get("kcalSum")?.takeIf { !it.isNull } - ?.let { return it.doubleValue() } - point["totalCalories"]?.get("kilocalories")?.takeIf { !it.isNull } - ?.let { return it.doubleValue() } - point["value"]?.get("totalCalories")?.get("kcalSum")?.takeIf { !it.isNull } - ?.let { return it.doubleValue() } - point["kilocalories"]?.takeIf { !it.isNull }?.let { return it.doubleValue() } - return null - } }