Stian Fredrik Aune
2 years ago
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