9 Commits
Author | SHA1 | Message | Date |
---|---|---|---|
|
fb67243edb | Fungerer med nye trimsykkelen nå. (Mye rot i koden da) | 4 months ago |
|
f30851da48 |
Remove Indigo support.
|
6 months ago |
|
e9093a0230 |
Remove mystery services.
|
6 months ago |
|
b2ff762706 | Support indigo3 | 12 months ago |
|
5730609665 | Quick and dirty calorie scale option | 2 years ago |
|
fc177c16a5 |
Statistikkblokk på Venstre/høyre
|
2 years ago |
|
c867638b9a |
git add .
|
2 years ago |
|
34c96e12e0 |
Merge branch 'main' of git.aiterp.net:ykonsole2/ykonsole2
|
2 years ago |
|
e516c32ede |
Indigo2 claim option
|
2 years ago |
23 changed files with 429 additions and 587 deletions
-
2pom.xml
-
3webui-react/src/hooks/kpm.ts
-
31webui-react/src/hooks/storage.ts
-
14webui-react/src/pages/IndexPage.tsx
-
4webui-react/src/pages/PlayPage.tsx
-
8ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/application/services/DriverStarter.kt
-
2ykonsole-core/src/main/resources/log4j2.xml
-
57ykonsole-exporter/pom.xml
-
23ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/ExportTarget.kt
-
57ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/WorkoutExporter.kt
-
177ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/indigo1/Indigo1.kt
-
171ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/indigo2/Indigo2.kt
-
2ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/Demo.kt
-
97ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/IConsole.kt
-
8ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/iconsole/Request.kt
-
4ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/iconsole/Response.kt
-
37ykonsole-jarun/pom.xml
-
74ykonsole-jarun/src/main/kotlin/net/aiterp/git/ykonsole2/JarunDemo.kt
-
121ykonsole-jarun/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/Jarun.kt
-
61ykonsole-jarun/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/Types.kt
-
2ykonsole-ktor/pom.xml
-
4ykonsole-server/pom.xml
-
45ykonsole-server/src/main/kotlin/net/aiterp/git/ykonsole2/Server.kt
@ -0,0 +1,31 @@ |
|||||
|
import {Dispatch, SetStateAction, useEffect, useState} from "react"; |
||||
|
|
||||
|
function loadData<T>(key: string, defValue: T): T { |
||||
|
const newData = window.localStorage.getItem(`ykonsole2.${key}`); |
||||
|
if (newData === null) { |
||||
|
return defValue; |
||||
|
} |
||||
|
|
||||
|
return JSON.parse(newData) as T; |
||||
|
} |
||||
|
|
||||
|
export default function useLocalStorage<T>(key: string, defValue: T): [T, Dispatch<SetStateAction<T>>] { |
||||
|
const [data, setData] = useState(() => loadData(key, defValue)); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
window.localStorage.setItem(`ykonsole2.${key}`, JSON.stringify(data)); |
||||
|
}, [key, data]); |
||||
|
|
||||
|
useEffect(() => { |
||||
|
const callback = () => { |
||||
|
setData(loadData(key, defValue)); |
||||
|
}; |
||||
|
|
||||
|
window.addEventListener("storage", callback); |
||||
|
return () => { |
||||
|
window.removeEventListener("storage", callback); |
||||
|
}; |
||||
|
}, [key, defValue]); |
||||
|
|
||||
|
return [data, setData]; |
||||
|
} |
@ -1,57 +0,0 @@ |
|||||
<?xml version="1.0" encoding="UTF-8"?> |
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" |
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> |
|
||||
<parent> |
|
||||
<artifactId>ykonsole</artifactId> |
|
||||
<groupId>net.aiterp.git.trimlog</groupId> |
|
||||
<version>2.0.0</version> |
|
||||
</parent> |
|
||||
<modelVersion>4.0.0</modelVersion> |
|
||||
|
|
||||
<artifactId>ykonsole-exporter</artifactId> |
|
||||
|
|
||||
<properties> |
|
||||
<maven.compiler.source>11</maven.compiler.source> |
|
||||
<maven.compiler.target>11</maven.compiler.target> |
|
||||
</properties> |
|
||||
|
|
||||
<repositories> |
|
||||
<repository> |
|
||||
<snapshots> |
|
||||
<enabled>false</enabled> |
|
||||
</snapshots> |
|
||||
<id>central</id> |
|
||||
<name>bintray</name> |
|
||||
<url>https://jcenter.bintray.com</url> |
|
||||
</repository> |
|
||||
</repositories> |
|
||||
|
|
||||
<dependencies> |
|
||||
<dependency> |
|
||||
<groupId>com.fasterxml.jackson.core</groupId> |
|
||||
<artifactId>jackson-databind</artifactId> |
|
||||
<version>2.14.0</version> |
|
||||
</dependency> |
|
||||
<dependency> |
|
||||
<groupId>com.fasterxml.jackson.module</groupId> |
|
||||
<artifactId>jackson-module-kotlin</artifactId> |
|
||||
<version>2.14.0</version> |
|
||||
</dependency> |
|
||||
<dependency> |
|
||||
<groupId>net.aiterp.git.trimlog</groupId> |
|
||||
<artifactId>ykonsole-core</artifactId> |
|
||||
<version>${project.version}</version> |
|
||||
</dependency> |
|
||||
<dependency> |
|
||||
<groupId>me.lazmaid.kraph</groupId> |
|
||||
<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> |
|
@ -1,23 +0,0 @@ |
|||||
package net.aiterp.git.ykonsole2.infrastructure |
|
||||
|
|
||||
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 |
|
||||
|
|
||||
interface ExportTarget { |
|
||||
/** |
|
||||
* Check if [workout] is already exported. |
|
||||
*/ |
|
||||
suspend fun isExported(workout: Workout): Boolean |
|
||||
|
|
||||
/** |
|
||||
* Export a [Workout] with its pertaining [WorkoutState]s, [Device] and [Program]. |
|
||||
*/ |
|
||||
suspend fun export( |
|
||||
workout: Workout, |
|
||||
workoutStates: List<WorkoutState>, |
|
||||
device: Device?, |
|
||||
program: Program?, |
|
||||
) |
|
||||
} |
|
@ -1,57 +0,0 @@ |
|||||
package net.aiterp.git.ykonsole2.infrastructure |
|
||||
|
|
||||
import net.aiterp.git.ykonsole2.application.logging.log |
|
||||
import net.aiterp.git.ykonsole2.domain.models.* |
|
||||
import net.aiterp.git.ykonsole2.domain.runtime.* |
|
||||
import net.aiterp.git.ykonsole2.infrastructure.drivers.abstracts.ReactiveDriver |
|
||||
import java.time.Instant |
|
||||
import java.time.temporal.ChronoUnit |
|
||||
|
|
||||
class WorkoutExporter( |
|
||||
private val workoutRepo: WorkoutRepository, |
|
||||
private val stateRepo: WorkoutStateRepository, |
|
||||
private val deviceRepo: DeviceRepository, |
|
||||
private val programRepo: ProgramRepository, |
|
||||
private val exportTarget: ExportTarget, |
|
||||
) : ReactiveDriver() { |
|
||||
private var workoutId: String = "" |
|
||||
|
|
||||
override suspend fun onEvent(event: Event, input: FlowBus<Command>) { |
|
||||
if (event is Connected && event.initial) { |
|
||||
workoutId = workoutRepo.findActive()?.id ?: "" |
|
||||
} |
|
||||
|
|
||||
if (event is Disconnected && workoutId != "") { |
|
||||
export(workoutId) |
|
||||
workoutId = "" |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
override suspend fun start(input: FlowBus<Command>, output: FlowBus<Event>) { |
|
||||
log.info("Checking recent workouts...") |
|
||||
val yesterday = Instant.now().minus(1, ChronoUnit.HOURS) |
|
||||
for (workout in workoutRepo.fetchAll().filter { it.createdAt > yesterday }) { |
|
||||
export(workout) |
|
||||
} |
|
||||
log.info("Recent workouts verified and exported if needed") |
|
||||
|
|
||||
super.start(input, output) |
|
||||
} |
|
||||
|
|
||||
private suspend inline fun export(workoutId: String) { |
|
||||
val workout = workoutRepo.findById(workoutId) ?: return |
|
||||
export(workout) |
|
||||
} |
|
||||
|
|
||||
private suspend inline fun export(workout: Workout) { |
|
||||
if (workout.test) return |
|
||||
|
|
||||
val states = stateRepo.fetchByWorkoutId(workout.id) |
|
||||
if (states.isEmpty() || exportTarget.isExported(workout)) return |
|
||||
|
|
||||
val device = deviceRepo.findById(workout.deviceId) |
|
||||
val program = workout.programId?.let { programRepo.findById(it) } |
|
||||
|
|
||||
exportTarget.export(workout, states, device, program) |
|
||||
} |
|
||||
} |
|
@ -1,177 +0,0 @@ |
|||||
package net.aiterp.git.ykonsole2.infrastructure.indigo1 |
|
||||
|
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper |
|
||||
import kotlinx.coroutines.future.await |
|
||||
import me.lazmaid.kraph.Kraph |
|
||||
import net.aiterp.git.ykonsole2.StorageException |
|
||||
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.BodyHandlers |
|
||||
import java.time.LocalDate |
|
||||
import java.time.LocalTime |
|
||||
import java.time.ZoneId |
|
||||
import java.time.ZoneOffset |
|
||||
import java.time.temporal.ChronoUnit |
|
||||
import java.util.* |
|
||||
|
|
||||
class Indigo1( |
|
||||
private val endpoint: String, |
|
||||
private val clientId: String, |
|
||||
private val clientSecret: String, |
|
||||
) : ExportTarget { |
|
||||
private val logger = log |
|
||||
|
|
||||
override suspend fun isExported(workout: Workout): Boolean { |
|
||||
val ids = run { |
|
||||
query { |
|
||||
fieldObject( |
|
||||
"exercises", args = mapOf( |
|
||||
"filter" to mapOf( |
|
||||
"fromDate" to workout.createdAt.minus(7, ChronoUnit.DAYS).atZone(ZoneOffset.UTC).toLocalDate().toString(), |
|
||||
"kindId" to 3, |
|
||||
"tags" to listOf(tag("ykonsole:Version", "2"), tag("ykonsole:WorkoutID", workout.id)), |
|
||||
) |
|
||||
) |
|
||||
) { |
|
||||
field("id") |
|
||||
} |
|
||||
} |
|
||||
}.data.exercises ?: return false |
|
||||
|
|
||||
return ids.isNotEmpty() |
|
||||
} |
|
||||
|
|
||||
override suspend fun export( |
|
||||
workout: Workout, |
|
||||
workoutStates: List<WorkoutState>, |
|
||||
device: Device?, |
|
||||
program: Program?, |
|
||||
) { |
|
||||
logger.info("Creating exercise...") |
|
||||
val exerciseId = run { |
|
||||
mutation { |
|
||||
fieldObject( |
|
||||
"addExercise", args = mapOf( |
|
||||
"options" to mapOf( |
|
||||
"kindId" to 3, |
|
||||
"partOfDayId" to workout.partOfDayId, |
|
||||
"date" to workout.date.toString(), |
|
||||
), |
|
||||
), |
|
||||
) { field("id") } |
|
||||
} |
|
||||
}.data.addExercise?.id ?: throw StorageException("Failed to create exercise") |
|
||||
logger.info("Created exercise with ID $exerciseId") |
|
||||
|
|
||||
logger.info("Exporting states for exercise with ID $exerciseId...") |
|
||||
for (chunk in workoutStates.chunked(100)) { |
|
||||
val calories = chunk.mapNotNull { ws -> |
|
||||
if (ws.calories != null) (ws.time.toInt() to ws.calories!!.toInt()) else null |
|
||||
} |
|
||||
val distance = chunk.mapNotNull { ws -> |
|
||||
if (ws.distance != null) (ws.time.toInt() to ws.distance!!.toInt().toDouble() / 1000) else null |
|
||||
} |
|
||||
|
|
||||
if (calories.isNotEmpty()) { |
|
||||
run { |
|
||||
mutation { |
|
||||
fieldObject( |
|
||||
"addMeasurementBatch", args = mapOf( |
|
||||
"exerciseId" to exerciseId, |
|
||||
"options" to calories.map { mapOf("point" to it.first, "value" to it.second) }, |
|
||||
) |
|
||||
) { field("id") } |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
if (distance.isNotEmpty()) { |
|
||||
run { |
|
||||
mutation { |
|
||||
fieldObject( |
|
||||
"addMetadataBatch", args = mapOf( |
|
||||
"exerciseId" to exerciseId, |
|
||||
"options" to distance.map { mapOf("point" to it.first, "kindId" to 5, "value" to it.second) }, |
|
||||
) |
|
||||
) { field("id") } |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
logger.info("Exporting tags for exercise with ID $exerciseId...") |
|
||||
run { |
|
||||
mutation { |
|
||||
fieldObject( |
|
||||
"addTagBatch", args = mapOf( |
|
||||
"exerciseId" to exerciseId, |
|
||||
"options" 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}"), |
|
||||
) |
|
||||
) |
|
||||
) { |
|
||||
field("id") |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
private suspend fun run(func: Kraph.() -> Unit): Output { |
|
||||
val query = Kraph { func() } |
|
||||
|
|
||||
val request = HttpRequest.newBuilder() |
|
||||
.uri(URI.create(endpoint)) |
|
||||
.POST(BodyPublishers.ofString(query.toRequestString())) |
|
||||
.header("Content-Type", "application/json") |
|
||||
.header("Authorization", "Basic ${Base64.getEncoder().encodeToString("$clientId:$clientSecret".toByteArray())}") |
|
||||
.build() |
|
||||
|
|
||||
val response = HttpClient.newHttpClient() |
|
||||
.sendAsync(request, BodyHandlers.ofString()) |
|
||||
.await() |
|
||||
|
|
||||
return jackson.readValue(response.body(), Output::class.java) |
|
||||
} |
|
||||
|
|
||||
private val jackson = jacksonObjectMapper() |
|
||||
|
|
||||
private data class Output(val data: Data) |
|
||||
|
|
||||
private data class Data( |
|
||||
val addExercise: ExerciseOutput? = null, |
|
||||
val addMeasurementBatch: List<ExerciseOutput>? = null, |
|
||||
val addMetadataBatch: List<ExerciseOutput>? = null, |
|
||||
val addTagBatch: List<ExerciseOutput>? = null, |
|
||||
val exercises: List<ExerciseOutput>? = null, |
|
||||
) |
|
||||
|
|
||||
private data class ExerciseOutput(val id: Int) |
|
||||
|
|
||||
private val Workout.partOfDayId |
|
||||
get() = when (LocalTime.ofInstant(createdAt, zone).hour) { |
|
||||
in 5..11 -> "M" |
|
||||
in 12..17 -> "A" |
|
||||
in 18..22 -> "E" |
|
||||
else -> "N" |
|
||||
} |
|
||||
private val Workout.date get() = LocalDate.ofInstant(createdAt, zone) |
|
||||
|
|
||||
private val zone = ZoneId.of("Europe/Oslo") |
|
||||
|
|
||||
private fun tag(key: String, value: String) = mapOf("key" to key, "value" to value) |
|
||||
} |
|
@ -1,171 +0,0 @@ |
|||||
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, 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(), |
|
||||
"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) |
|
||||
} |
|
@ -0,0 +1,37 @@ |
|||||
|
<?xml version="1.0" encoding="UTF-8"?> |
||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0" |
||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> |
||||
|
<modelVersion>4.0.0</modelVersion> |
||||
|
<parent> |
||||
|
<groupId>net.aiterp.git.trimlog</groupId> |
||||
|
<artifactId>ykonsole</artifactId> |
||||
|
<version>2.0.0</version> |
||||
|
</parent> |
||||
|
|
||||
|
<artifactId>ykonsole-jarun</artifactId> |
||||
|
|
||||
|
<properties> |
||||
|
<maven.compiler.source>11</maven.compiler.source> |
||||
|
<maven.compiler.target>11</maven.compiler.target> |
||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> |
||||
|
</properties> |
||||
|
|
||||
|
<dependencies> |
||||
|
<dependency> |
||||
|
<groupId>net.aiterp.git.trimlog</groupId> |
||||
|
<artifactId>ykonsole-core</artifactId> |
||||
|
<version>${project.version}</version> |
||||
|
</dependency> |
||||
|
<dependency> |
||||
|
<groupId>com.fasterxml.jackson.core</groupId> |
||||
|
<artifactId>jackson-databind</artifactId> |
||||
|
<version>2.16.1</version> |
||||
|
</dependency> |
||||
|
<dependency> |
||||
|
<groupId>com.fasterxml.jackson.module</groupId> |
||||
|
<artifactId>jackson-module-kotlin</artifactId> |
||||
|
<version>2.16.1</version> |
||||
|
</dependency> |
||||
|
</dependencies> |
||||
|
</project> |
@ -0,0 +1,74 @@ |
|||||
|
package net.aiterp.git.ykonsole2 |
||||
|
|
||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper |
||||
|
import kotlinx.coroutines.delay |
||||
|
import kotlinx.coroutines.launch |
||||
|
import kotlinx.coroutines.runBlocking |
||||
|
import net.aiterp.git.ykonsole2.application.logging.log |
||||
|
import net.aiterp.git.ykonsole2.domain.models.Device |
||||
|
import net.aiterp.git.ykonsole2.domain.models.randomId |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.* |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.Jarun |
||||
|
import java.util.concurrent.atomic.AtomicInteger |
||||
|
import kotlin.system.exitProcess |
||||
|
import kotlin.time.Duration.Companion.milliseconds |
||||
|
import kotlin.time.Duration.Companion.seconds |
||||
|
|
||||
|
object JarunDemo |
||||
|
|
||||
|
fun main(): Unit = runBlocking { |
||||
|
val logger = JarunDemo.log |
||||
|
logger.info("Starting Jarun demo...") |
||||
|
|
||||
|
val input = CommandBus() |
||||
|
val output = EventBus() |
||||
|
val device = Device(randomId(), "Jarun", "jarun:127.0.0.1:8555") |
||||
|
|
||||
|
launch { |
||||
|
Jarun(jacksonObjectMapper()).start(input, output) |
||||
|
} |
||||
|
|
||||
|
launch { |
||||
|
delay(1000.milliseconds) |
||||
|
input.emit(ConnectCommand(device)) |
||||
|
} |
||||
|
|
||||
|
val health = AtomicInteger(2) |
||||
|
output.collect(forceAll = true) { event -> |
||||
|
logger.info("Received event: $event") |
||||
|
|
||||
|
if (event is Connected) { |
||||
|
delay(1.seconds) |
||||
|
input.emit(StartCommand) |
||||
|
} |
||||
|
|
||||
|
if (event is Started) { |
||||
|
input.emit(SetValueCommand(Level(12))) |
||||
|
} |
||||
|
|
||||
|
if (event is ValuesReceived && event.values.findInt<Time>() == 35) { |
||||
|
input.emit(SetValueCommand(Level(18))) |
||||
|
} |
||||
|
|
||||
|
if (event is ValuesReceived && event.values.findInt<Time>() == 70) { |
||||
|
input.emit(StopCommand) |
||||
|
} |
||||
|
|
||||
|
if (event is ValuesReceived && event.values.findInt<Time>() == 80) { |
||||
|
input.emit(StopCommand) |
||||
|
} |
||||
|
|
||||
|
if (event == Stopped) { |
||||
|
if (health.decrementAndGet() == 0) { |
||||
|
input.emit(DisconnectCommand) |
||||
|
} else { |
||||
|
delay(12.seconds) |
||||
|
input.emit(StartCommand) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (event == Disconnected) { |
||||
|
exitProcess(0) |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,121 @@ |
|||||
|
package net.aiterp.git.ykonsole2.infrastructure |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.ObjectMapper |
||||
|
import kotlinx.coroutines.delay |
||||
|
import net.aiterp.git.ykonsole2.application.logging.log |
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.* |
||||
|
import net.aiterp.git.ykonsole2.infrastructure.drivers.abstracts.ActiveDriver |
||||
|
import java.net.Socket |
||||
|
|
||||
|
class Jarun(private val objectMapper: ObjectMapper) : ActiveDriver() { |
||||
|
private var host: String = "" |
||||
|
private var port: Int = -1 |
||||
|
|
||||
|
private var socket: Socket? = null |
||||
|
private var active: Boolean = false |
||||
|
private var status: MachineStatus? = null |
||||
|
private var lastTime = 0 |
||||
|
|
||||
|
private var state: JarunState = JarunState.Available |
||||
|
|
||||
|
override suspend fun onCommand(command: Command, output: FlowBus<Event>) { |
||||
|
if (!active && command !is ConnectCommand) { |
||||
|
// Don't interfere with other drivers |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
when (command) { |
||||
|
is ConnectCommand -> { |
||||
|
val bits = command.device.connectionString.split(':') |
||||
|
if (bits.size < 3 || bits[0] != "jarun") return |
||||
|
|
||||
|
socket?.close() |
||||
|
socket = null |
||||
|
host = bits[1] |
||||
|
port = bits[2].toInt() |
||||
|
active = true |
||||
|
status = null |
||||
|
lastTime = 0 |
||||
|
state = JarunState.Connecting |
||||
|
|
||||
|
log.info("Connecting to $host:$port...") |
||||
|
fetch(JarunInput("connect")) |
||||
|
} |
||||
|
DisconnectCommand -> fetch(JarunInput("disconnect")) |
||||
|
is SetValueCommand -> when (command.value) { |
||||
|
is Level -> fetch(JarunInput("targetLevel", command.value.toInt())) |
||||
|
else -> error("Unsupported value") |
||||
|
} |
||||
|
SkipCommand -> { /* Ignored */ } |
||||
|
StartCommand -> fetch(JarunInput("start")) |
||||
|
StopCommand -> fetch(JarunInput("stop")) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
override suspend fun onTick(output: FlowBus<Event>) { |
||||
|
if (active) { |
||||
|
val res = fetch(JarunInput(command = "status")) |
||||
|
|
||||
|
if (res.success && res.status != null) { |
||||
|
if (res.status != status) { |
||||
|
val newState = when { |
||||
|
res.status.active -> JarunState.Started |
||||
|
res.status.connected -> JarunState.Connected |
||||
|
res.status.ready -> JarunState.Available |
||||
|
else -> JarunState.Connecting |
||||
|
} |
||||
|
|
||||
|
if (newState != state) { |
||||
|
when (newState) { |
||||
|
JarunState.Available -> { |
||||
|
output.emit(Disconnected) |
||||
|
active = false |
||||
|
} |
||||
|
JarunState.Connecting -> { /* Nothing */ } |
||||
|
JarunState.Connected -> { |
||||
|
if (state == JarunState.Connecting) { |
||||
|
output.emit(Connected(true)) |
||||
|
} else { |
||||
|
output.emit(Stopped) |
||||
|
} |
||||
|
} |
||||
|
JarunState.Started -> output.emit(Started) |
||||
|
} |
||||
|
state = newState |
||||
|
} |
||||
|
|
||||
|
if (res.status.time > lastTime) { |
||||
|
output.emit(ValuesReceived(res.status.values())) |
||||
|
lastTime = res.status.time |
||||
|
} |
||||
|
status = res.status |
||||
|
} |
||||
|
} else if (res.error != null) { |
||||
|
output.emit(ErrorOccurred(res.error)) |
||||
|
} |
||||
|
} |
||||
|
delay(250) |
||||
|
} |
||||
|
|
||||
|
@Synchronized |
||||
|
private fun fetch(input: JarunInput): JarunOutput { |
||||
|
val socket = tcp() |
||||
|
|
||||
|
socket.getOutputStream().apply { |
||||
|
write(objectMapper.writeValueAsBytes(input)) |
||||
|
flush() |
||||
|
} |
||||
|
|
||||
|
val buffer = ByteArray(1024) |
||||
|
socket.getInputStream().read(buffer) |
||||
|
return objectMapper.readValue(buffer, JarunOutput::class.java) |
||||
|
} |
||||
|
|
||||
|
private fun tcp(): Socket { |
||||
|
if (socket == null || socket!!.isClosed) { |
||||
|
socket = Socket(host, port) |
||||
|
} |
||||
|
|
||||
|
return socket!! |
||||
|
} |
||||
|
} |
@ -0,0 +1,61 @@ |
|||||
|
package net.aiterp.git.ykonsole2.infrastructure |
||||
|
|
||||
|
import net.aiterp.git.ykonsole2.domain.runtime.* |
||||
|
|
||||
|
internal data class JarunInput( |
||||
|
val command: String, |
||||
|
val value: Int = 0, |
||||
|
) |
||||
|
|
||||
|
internal data class JarunOutput( |
||||
|
val success: Boolean = false, |
||||
|
val metadata: MachineMetadata? = null, |
||||
|
val status: MachineStatus? = null, |
||||
|
val error: String? = null, |
||||
|
) |
||||
|
|
||||
|
internal data class MachineMetadata( |
||||
|
val id: String, |
||||
|
val name: String, |
||||
|
val capability: MachineCapability, |
||||
|
) |
||||
|
|
||||
|
internal data class MachineStatus( |
||||
|
val ready: Boolean = false, |
||||
|
val connected: Boolean = false, |
||||
|
val active: Boolean = false, |
||||
|
val targetSpeed: Int = 0, |
||||
|
val targetLevel: Int = 0, |
||||
|
val time: Int = 0, |
||||
|
val power: Int = 0, |
||||
|
val energy: Int = 0, |
||||
|
val distance: Int = 0, |
||||
|
val speed: Int = 0, |
||||
|
val pulse: Int = 0, |
||||
|
) { |
||||
|
fun values() = buildList { |
||||
|
add(Time(time)) |
||||
|
if (energy > 0) add(Calories(energy)) |
||||
|
if (distance > 0) add(Distance(distance)) |
||||
|
if (speed > 0) add(RpmSpeed(speed)) |
||||
|
if (targetLevel > 0) add(Level(targetLevel)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
internal typealias MachineCapability = Int |
||||
|
|
||||
|
internal object MachineCapabilities { |
||||
|
const val TARGET_SPEED: MachineCapability = 1 shl 0 |
||||
|
const val TARGET_LEVEL: MachineCapability = 1 shl 1 |
||||
|
const val TIME: MachineCapability = 1 shl 2 |
||||
|
const val POWER: MachineCapability = 1 shl 3 |
||||
|
const val ENERGY: MachineCapability = 1 shl 4 |
||||
|
const val DISTANCE: MachineCapability = 1 shl 5 |
||||
|
const val RPM_SPEED: MachineCapability = 1 shl 6 |
||||
|
const val KHM_SPEED: MachineCapability = 1 shl 7 |
||||
|
const val PULSE: MachineCapability = 1 shl 8 |
||||
|
|
||||
|
operator fun MachineCapability.contains(cap: MachineCapability) = (this and cap) == cap |
||||
|
} |
||||
|
|
||||
|
enum class JarunState { Available, Connecting, Connected, Started } |
Write
Preview
Loading…
Cancel
Save
Reference in new issue