4 changed files with 195 additions and 3 deletions
-
9ykonsole-exporter/pom.xml
-
171ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/indigo2/Indigo2.kt
-
1ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/plugins/Install.kt
-
17ykonsole-server/src/main/kotlin/net/aiterp/git/ykonsole2/Server.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<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) |
||||
|
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue