diff --git a/ykonsole-exporter/pom.xml b/ykonsole-exporter/pom.xml index 2da9312..63993cc 100644 --- a/ykonsole-exporter/pom.xml +++ b/ykonsole-exporter/pom.xml @@ -31,12 +31,12 @@ com.fasterxml.jackson.core jackson-databind - 2.13.3 + 2.14.0 com.fasterxml.jackson.module jackson-module-kotlin - 2.13.3 + 2.14.0 net.aiterp.git.trimlog @@ -48,5 +48,10 @@ kraph 0.6.1 + + io.github.shyamz-22 + oidc-jvm-client + 0.2.8 + diff --git a/ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/indigo2/Indigo2.kt b/ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/indigo2/Indigo2.kt new file mode 100644 index 0000000..a245a08 --- /dev/null +++ b/ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/indigo2/Indigo2.kt @@ -0,0 +1,171 @@ +package net.aiterp.git.ykonsole2.infrastructure.indigo2 + +import com.fasterxml.jackson.annotation.JsonAlias +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import kotlinx.coroutines.future.await +import net.aiterp.git.ykonsole2.application.logging.log +import net.aiterp.git.ykonsole2.domain.models.Device +import net.aiterp.git.ykonsole2.domain.models.Program +import net.aiterp.git.ykonsole2.domain.models.Workout +import net.aiterp.git.ykonsole2.domain.models.WorkoutState +import net.aiterp.git.ykonsole2.infrastructure.ExportTarget +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpRequest.BodyPublishers +import java.net.http.HttpResponse +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.util.* + +class Indigo2( + private val indigoHost: String, + private val oidcTokenEndpoint: String, + private val oidcClientId: String, + private val oidcClientSecret: String, +) : ExportTarget { + private val om = jacksonObjectMapper().apply { + configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + } + private val logger = log + + override suspend fun isExported(workout: Workout): Boolean { + val paths = listOf( + "/api/workouts?from=${workout.date}&to=${workout.date}", + "/api/activities", + ) + + return paths.any { path -> + val request = HttpRequest.newBuilder() + .uri(URI.create(indigoHost + path)) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer ${authenticate()}") + .build() + + val response = HttpClient.newHttpClient() + .sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()) + .await() + + val result = om.readValue(response.body(), object : TypeReference>>() {}) + + result.data?.any { existingWorkout -> + existingWorkout.tags.any { it.key == "ykonsole:WorkoutID" && it.value == existingWorkout.id } + } ?: false + } + } + + override suspend fun export(workout: Workout, workoutStates: List, device: Device?, program: Program?) { + if (workoutStates.size < 60) return + + val chunks = workoutStates.chunked(2000) + + val firstBody = mapOf( + "activity" to mapOf( + "kind" to "ExerciseBike", + "measurements" to tm(chunks.first()), + ), + "claimed" to true, + "date" to workout.date.toString(), + "tags" to listOfNotNull( + tag("ykonsole:Version", "2"), + tag("ykonsole:WorkoutID", workout.id), + workout.message.takeIf(String::isNotBlank)?.let { tag("ykonsole:ErrorMessage", it) }, + tag("ykonsole:DeviceID", workout.deviceId), + device?.let { tag("ykonsole:DeviceName", it.name) }, + program?.let { tag("ykonsole:ProgramID", it.id) }, + program?.let { tag("ykonsole:ProgramName", it.name) }, + tag("ykonsole:CreatedAt", "${workout.createdAt}"), + ) + ) + + val request = HttpRequest.newBuilder() + .uri(URI.create("$indigoHost/api/activities")) + .POST(BodyPublishers.ofByteArray(om.writeValueAsBytes(firstBody))) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer ${authenticate()}") + .build() + + val response = HttpClient.newHttpClient().sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()).await() + val result = om.readValue(response.body(), object : TypeReference>() {}) + if (result.code != 200) { + logger.error("Activity push failed (first chunk): ${result.message}") + return + } + + chunks.subList(1).forEach { chunk -> + val chBody = mapOf( + "newMeasurements" to tm(chunk), + ) + + val chRequest = HttpRequest.newBuilder() + .uri(URI.create("$indigoHost/api/activities/${result.data?.id}")) + .PUT(BodyPublishers.ofByteArray(om.writeValueAsBytes(chBody))) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer ${authenticate()}") + .build() + + val chRes = HttpClient.newHttpClient().sendAsync(chRequest, HttpResponse.BodyHandlers.ofInputStream()).await() + val chResult = om.readValue(chRes.body(), PostIndigoResult::class.java) + if (chResult.code != 200) { + logger.error("Activity push failed (later chunks): ${result.message}") + return + } + } + } + + private val Workout.date get() = LocalDate.ofInstant(createdAt, zone) + private val zone = ZoneId.of("Europe/Oslo") + + private var token: String = "" + private var tokenExpiry = Instant.MIN + private fun authenticate(): String = synchronized(this) { + if (tokenExpiry < Instant.now()) { + val request = HttpRequest.newBuilder() + .uri(URI.create(oidcTokenEndpoint)) + .POST(BodyPublishers.ofString("grant_type=client_credentials&scope=indigo/api")) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Authorization", "Basic ${Base64.getEncoder().encodeToString("$oidcClientId:$oidcClientSecret".toByteArray())}") + .build() + + val response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofInputStream()) + + val result = om.readValue(response.body(), TokenResult::class.java) + + token = result.accessToken + tokenExpiry = Instant.now().plusSeconds(result.expiresIn - 300L) + } + + token + } + + data class ExistingWorkout(val id: String, val tags: List) + data class Tag(val key: String, val value: String) + data class TokenResult( + @JsonAlias("access_token") val accessToken: String, + @JsonAlias("expires_in") val expiresIn: Int, + ) + data class IndigoResult( + val code: Int, + val message: String, + val data: T?, + ) + data class PostIndigoResult( + val code: Int, + val message: String, + ) + + private fun tm(list: List) = list.mapNotNull { ws -> + if (ws.time.seconds > 0) mapOf( + "seconds" to ws.time.toInt(), + "meters" to ws.distance?.toInt(), + "calories" to ws.calories?.toInt(), + "resistance" to ws.level?.toInt(), + "rpmSpeed" to ws.rpmSpeed?.toInt(), + "pulse" to ws.pulse?.toInt(), + ) else null + } + private fun tag(key: String, value: String) = mapOf("key" to key, "value" to value) +} diff --git a/ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/plugins/Install.kt b/ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/plugins/Install.kt index 189e4fd..bf2fe07 100644 --- a/ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/plugins/Install.kt +++ b/ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/plugins/Install.kt @@ -1,6 +1,5 @@ package net.aiterp.git.ykonsole2.application.plugins -import com.fasterxml.jackson.databind.JsonMappingException import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule diff --git a/ykonsole-server/src/main/kotlin/net/aiterp/git/ykonsole2/Server.kt b/ykonsole-server/src/main/kotlin/net/aiterp/git/ykonsole2/Server.kt index 19c03a6..b66ba70 100644 --- a/ykonsole-server/src/main/kotlin/net/aiterp/git/ykonsole2/Server.kt +++ b/ykonsole-server/src/main/kotlin/net/aiterp/git/ykonsole2/Server.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.runBlocking import net.aiterp.git.ykonsole2.application.createServer import net.aiterp.git.ykonsole2.application.env.optStrEnv import net.aiterp.git.ykonsole2.application.env.strEnv +import net.aiterp.git.ykonsole2.application.logging.log import net.aiterp.git.ykonsole2.application.services.DriverStarter import net.aiterp.git.ykonsole2.domain.models.* import net.aiterp.git.ykonsole2.domain.runtime.CommandBus @@ -16,6 +17,7 @@ import net.aiterp.git.ykonsole2.infrastructure.drivers.ProgramEnforcer import net.aiterp.git.ykonsole2.infrastructure.drivers.Skipper import net.aiterp.git.ykonsole2.infrastructure.drivers.WorkoutWriter import net.aiterp.git.ykonsole2.infrastructure.indigo1.Indigo1 +import net.aiterp.git.ykonsole2.infrastructure.indigo2.Indigo2 import net.aiterp.git.ykonsole2.infrastructure.makeDataSource import net.aiterp.git.ykonsole2.infrastructure.repositories.deviceRepo import net.aiterp.git.ykonsole2.infrastructure.repositories.programRepo @@ -90,6 +92,7 @@ private data class RepositorySet( ) { fun workoutExporterOrNull(): WorkoutExporter? { val exportTarget = makeExportTarget() ?: return null + log.info("Export target set: ${exportTarget.javaClass.simpleName}") return WorkoutExporter(workoutRepo, workoutStateRepo, deviceRepo, programRepo, exportTarget) } @@ -103,6 +106,20 @@ private data class RepositorySet( return Indigo1(indigo1Endpoint, clientId, clientSecret) } + val indigo2Endpoint = optStrEnv("INDIGO2_HOST") + if (indigo2Endpoint != null) { + val tokenEndpoint = strEnv("INDIGO2_OIDC_TOKEN_ENDPOINT") + val clientId = strEnv("INDIGO2_OIDC_CLIENT_ID") + val clientSecret = strEnv("INDIGO2_OIDC_CLIENT_SECRET") + + return Indigo2( + indigoHost = indigo2Endpoint, + oidcTokenEndpoint = tokenEndpoint, + oidcClientId = clientId, + oidcClientSecret = clientSecret, + ) + } + return null } }