You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

174 lines
6.4 KiB

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.*
import kotlin.math.roundToInt
class Indigo2(
private val indigoHost: String,
private val oidcTokenEndpoint: String,
private val oidcClientId: String,
private val oidcClientSecret: String,
private val autoClaim: Boolean,
private val calorieScale: Double = 1.0,
) : 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 autoClaim,
"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, chunks.size).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()?.let { (it * calorieScale).roundToInt() },
"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)
}