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