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
}
}