Browse Source

Indigo2 support

main
Stian Fredrik Aune 1 year ago
parent
commit
e3d58e7e20
  1. 9
      ykonsole-exporter/pom.xml
  2. 171
      ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/indigo2/Indigo2.kt
  3. 1
      ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/plugins/Install.kt
  4. 17
      ykonsole-server/src/main/kotlin/net/aiterp/git/ykonsole2/Server.kt

9
ykonsole-exporter/pom.xml

@ -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
ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/indigo2/Indigo2.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)
}

1
ykonsole-ktor/src/main/kotlin/net/aiterp/git/ykonsole2/application/plugins/Install.kt

@ -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

17
ykonsole-server/src/main/kotlin/net/aiterp/git/ykonsole2/Server.kt

@ -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
}
}
Loading…
Cancel
Save