@@ -31,12 +31,12 @@
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>2.13.3</version>
|
||||
<version>2.14.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.module</groupId>
|
||||
<artifactId>jackson-module-kotlin</artifactId>
|
||||
<version>2.13.3</version>
|
||||
<version>2.14.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.aiterp.git.trimlog</groupId>
|
||||
@@ -48,5 +48,10 @@
|
||||
<artifactId>kraph</artifactId>
|
||||
<version>0.6.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.github.shyamz-22</groupId>
|
||||
<artifactId>oidc-jvm-client</artifactId>
|
||||
<version>0.2.8</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
||||
+171
@@ -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<IndigoResult<List<ExistingWorkout>>>() {})
|
||||
|
||||
result.data?.any { existingWorkout ->
|
||||
existingWorkout.tags.any { it.key == "ykonsole:WorkoutID" && it.value == existingWorkout.id }
|
||||
} ?: false
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun export(workout: Workout, workoutStates: List<WorkoutState>, 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<IndigoResult<ExistingWorkout>>() {})
|
||||
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<Tag>)
|
||||
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<T : Any>(
|
||||
val code: Int,
|
||||
val message: String,
|
||||
val data: T?,
|
||||
)
|
||||
data class PostIndigoResult(
|
||||
val code: Int,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
private fun tm(list: List<WorkoutState>) = 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user