9 Commits
2.5.1 ... main

Author SHA1 Message Date
Stian Fredrik Aune fb67243edb Fungerer med nye trimsykkelen nå. (Mye rot i koden da) 4 months ago
Stian Fredrik Aune f30851da48 Remove Indigo support. 6 months ago
Stian Fredrik Aune e9093a0230 Remove mystery services. 6 months ago
Stian Fredrik Aune b2ff762706 Support indigo3 11 months ago
Stian Fredrik Aune 5730609665 Quick and dirty calorie scale option 2 years ago
Stian Fredrik Aune fc177c16a5 Statistikkblokk på Venstre/høyre 2 years ago
Stian Fredrik Aune c867638b9a git add . 2 years ago
Stian Fredrik Aune 34c96e12e0 Merge branch 'main' of git.aiterp.net:ykonsole2/ykonsole2 2 years ago
Stian Fredrik Aune e516c32ede Indigo2 claim option 2 years ago
  1. 2
      pom.xml
  2. 3
      webui-react/src/hooks/kpm.ts
  3. 31
      webui-react/src/hooks/storage.ts
  4. 14
      webui-react/src/pages/IndexPage.tsx
  5. 4
      webui-react/src/pages/PlayPage.tsx
  6. 8
      ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/application/services/DriverStarter.kt
  7. 2
      ykonsole-core/src/main/resources/log4j2.xml
  8. 57
      ykonsole-exporter/pom.xml
  9. 23
      ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/ExportTarget.kt
  10. 57
      ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/WorkoutExporter.kt
  11. 177
      ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/indigo1/Indigo1.kt
  12. 171
      ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/indigo2/Indigo2.kt
  13. 2
      ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/Demo.kt
  14. 97
      ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/IConsole.kt
  15. 8
      ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/iconsole/Request.kt
  16. 4
      ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/iconsole/Response.kt
  17. 37
      ykonsole-jarun/pom.xml
  18. 74
      ykonsole-jarun/src/main/kotlin/net/aiterp/git/ykonsole2/JarunDemo.kt
  19. 121
      ykonsole-jarun/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/Jarun.kt
  20. 61
      ykonsole-jarun/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/Types.kt
  21. 2
      ykonsole-ktor/pom.xml
  22. 4
      ykonsole-server/pom.xml
  23. 45
      ykonsole-server/src/main/kotlin/net/aiterp/git/ykonsole2/Server.kt

2
pom.xml

@ -11,7 +11,7 @@
<module>ykonsole-mysql</module> <module>ykonsole-mysql</module>
<module>ykonsole-server</module> <module>ykonsole-server</module>
<module>ykonsole-ktor</module> <module>ykonsole-ktor</module>
<module>ykonsole-exporter</module>
<module>ykonsole-jarun</module>
</modules> </modules>
<groupId>net.aiterp.git.trimlog</groupId> <groupId>net.aiterp.git.trimlog</groupId>
<version>2.0.0</version> <version>2.0.0</version>

3
webui-react/src/hooks/kpm.ts

@ -1,6 +1,5 @@
import {useContext, useEffect, useReducer} from "react"; import {useContext, useEffect, useReducer} from "react";
import RuntimeContext from "../contexts/RuntimeContext"; import RuntimeContext from "../contexts/RuntimeContext";
import {Values} from "../models/Shared";
import {WorkoutState} from "../models/Workouts"; import {WorkoutState} from "../models/Workouts";
interface UseKpmReducerState { interface UseKpmReducerState {
@ -28,7 +27,7 @@ export function useKpm() {
return { return {
lastStates: [...inRange, newState], lastStates: [...inRange, newState],
kpm: inRange.length > 30
kpm: duration >= 30
? Math.round(((newState.calories || 0) - (first.calories || 0)) * 60 / duration) ? Math.round(((newState.calories || 0) - (first.calories || 0)) * 60 / duration)
: 0, : 0,
} }

31
webui-react/src/hooks/storage.ts

@ -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];
}

14
webui-react/src/pages/IndexPage.tsx

@ -17,12 +17,14 @@ import {colorOf, WorkoutStatus} from "../models/Workouts";
import {faSpinner} from "@fortawesome/free-solid-svg-icons/faSpinner"; import {faSpinner} from "@fortawesome/free-solid-svg-icons/faSpinner";
import {useKey} from "../hooks/keyboard"; import {useKey} from "../hooks/keyboard";
import {Boi} from "../primitives/boi/Boi"; import {Boi} from "../primitives/boi/Boi";
import useLocalStorage from "../hooks/storage";
export default function IndexPage(): JSX.Element { export default function IndexPage(): JSX.Element {
const {devices} = useContext(DeviceContext); const {devices} = useContext(DeviceContext);
const {programs} = useContext(ProgramContext); const {programs} = useContext(ProgramContext);
const {workouts, loadingWorkouts, expanded, showMoreWorkouts, refreshWorkouts} = useContext(WorkoutContext); const {workouts, loadingWorkouts, expanded, showMoreWorkouts, refreshWorkouts} = useContext(WorkoutContext);
const navigate = useNavigate(); const navigate = useNavigate();
const [statPos, setStatPos] = useLocalStorage<"left" | "right">("stats.position", "left");
const isRunning = useMemo(() => workouts.some(w => w.status !== WorkoutStatus.Disconnected), [workouts]); const isRunning = useMemo(() => workouts.some(w => w.status !== WorkoutStatus.Disconnected), [workouts]);
@ -112,6 +114,18 @@ export default function IndexPage(): JSX.Element {
<BlobTextLine secondary>Legg til</BlobTextLine> <BlobTextLine secondary>Legg til</BlobTextLine>
</BlobText> </BlobText>
</Blob> </Blob>
<TitleLine>Plassering statistikblokk</TitleLine>
<Blob onClick={() => setStatPos("left")} color={statPos === "left" ? "blue" : "gray"}>
<BlobText>
<BlobTextLine>Til venstre</BlobTextLine>
</BlobText>
</Blob>
<Blob onClick={() => setStatPos("right")} color={statPos === "right" ? "blue" : "gray"}>
<BlobText>
<BlobTextLine>Til høyre</BlobTextLine>
</BlobText>
</Blob>
</PageFlexColumn> </PageFlexColumn>
</PageFlexRow> </PageFlexRow>
</PageBody> </PageBody>

4
webui-react/src/pages/PlayPage.tsx

@ -20,6 +20,7 @@ import MessageBoi from "./runtime/MessageBoi";
import ProgramBoi from "./runtime/ProgramBoi"; import ProgramBoi from "./runtime/ProgramBoi";
import MilestoneBoi from "./runtime/MilestoneBoi"; import MilestoneBoi from "./runtime/MilestoneBoi";
import {useKpm} from "../hooks/kpm"; import {useKpm} from "../hooks/kpm";
import useLocalStorage from "../hooks/storage";
function PlayPage(): JSX.Element { function PlayPage(): JSX.Element {
const {active, ready, ended, workout, reset, resume} = useContext(RuntimeContext); const {active, ready, ended, workout, reset, resume} = useContext(RuntimeContext);
@ -178,6 +179,7 @@ function RunPlayPage(): JSX.Element {
const {workout} = useContext(RuntimeContext); const {workout} = useContext(RuntimeContext);
const lastState = useLastState(); const lastState = useLastState();
const kpm = useKpm(); const kpm = useKpm();
const [statPos] = useLocalStorage<"left" | "right">("stats.position", "left");
if (!workout || workout.status === WorkoutStatus.Created) { if (!workout || workout.status === WorkoutStatus.Created) {
return <LoadingPage minimal/>; return <LoadingPage minimal/>;
@ -187,7 +189,7 @@ function RunPlayPage(): JSX.Element {
<Page title="YKonsole" background={"2046"}> <Page title="YKonsole" background={"2046"}>
<ControlsBoi/> <ControlsBoi/>
{lastState && ( {lastState && (
<Boi vertical="center" horizontal="left" style={{padding: "0.5vmax", paddingBottom: "0"}}>
<Boi vertical="center" horizontal={statPos} style={{padding: "0.5vmax", paddingBottom: "0"}}>
<span style={{fontSize: "125%"}}> <span style={{fontSize: "125%"}}>
<FluffyValue raw={lastState} valueKey="time"/> <FluffyValue raw={lastState} valueKey="time"/>
</span> </span>

8
ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/application/services/DriverStarter.kt

@ -1,13 +1,10 @@
package net.aiterp.git.ykonsole2.application.services package net.aiterp.git.ykonsole2.application.services
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.*
import net.aiterp.git.ykonsole2.domain.runtime.Command import net.aiterp.git.ykonsole2.domain.runtime.Command
import net.aiterp.git.ykonsole2.domain.runtime.Driver import net.aiterp.git.ykonsole2.domain.runtime.Driver
import net.aiterp.git.ykonsole2.domain.runtime.Event import net.aiterp.git.ykonsole2.domain.runtime.Event
import net.aiterp.git.ykonsole2.domain.runtime.FlowBus import net.aiterp.git.ykonsole2.domain.runtime.FlowBus
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import net.aiterp.git.ykonsole2.application.logging.log import net.aiterp.git.ykonsole2.application.logging.log
import java.lang.Exception import java.lang.Exception
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
@ -34,6 +31,7 @@ class DriverStarter(
while (true) { while (true) {
try { try {
driver.start(input, output) driver.start(input, output)
logger.info("Driver \"$name\" shut down normally")
} catch (e: Exception) { } catch (e: Exception) {
logger.error("Driver \"$name\" interrupted by exception: ${e.message}", e) logger.error("Driver \"$name\" interrupted by exception: ${e.message}", e)
@ -50,6 +48,6 @@ class DriverStarter(
} }
logger.info("Started ${drivers.size} drivers") logger.info("Started ${drivers.size} drivers")
jobs.forEach { it.join() }
jobs.joinAll()
} }
} }

2
ykonsole-core/src/main/resources/log4j2.xml

@ -11,7 +11,7 @@
</Console> </Console>
</Appenders> </Appenders>
<Loggers> <Loggers>
<Logger name="net.aiterp.git.ykonsole2" level="DEBUG" additivity="false">
<Logger name="net.aiterp.git.ykonsole2" level="INFO" additivity="false">
<AppenderRef ref="Console1"/> <AppenderRef ref="Console1"/>
</Logger> </Logger>
<Logger name="liquibase" level="INFO" additivity="false"> <Logger name="liquibase" level="INFO" additivity="false">

57
ykonsole-exporter/pom.xml

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

23
ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/ExportTarget.kt

@ -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?,
)
}

57
ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/WorkoutExporter.kt

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

177
ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/indigo1/Indigo1.kt

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

171
ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/indigo2/Indigo2.kt

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

2
ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/Demo.kt

@ -22,7 +22,7 @@ fun main() = runBlocking {
val input = CommandBus() val input = CommandBus()
val output = EventBus() val output = EventBus()
val device = Device(randomId(), "i-CONSOLE", "iconsole:E8:5D:86:02:27:E3")
val device = Device(randomId(), "i-CONSOLE", "iconsole:D2:33:36:33:5C:6F")
launch { launch {
IConsole().start(input, output) IConsole().start(input, output)

97
ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/IConsole.kt

@ -2,6 +2,7 @@ package net.aiterp.git.ykonsole2.infrastructure
import com.welie.blessed.* import com.welie.blessed.*
import com.welie.blessed.BluetoothGattCharacteristic.WriteType.WITHOUT_RESPONSE import com.welie.blessed.BluetoothGattCharacteristic.WriteType.WITHOUT_RESPONSE
import com.welie.blessed.BluetoothGattCharacteristic.WriteType.WITH_RESPONSE
import kotlinx.coroutines.* import kotlinx.coroutines.*
import net.aiterp.git.ykonsole2.domain.models.Device import net.aiterp.git.ykonsole2.domain.models.Device
import net.aiterp.git.ykonsole2.domain.runtime.* import net.aiterp.git.ykonsole2.domain.runtime.*
@ -97,38 +98,50 @@ class IConsole : ActiveDriver() {
if (tryingSince != null) { if (tryingSince != null) {
val cbPeripheral = object : BluetoothPeripheralCallback() { val cbPeripheral = object : BluetoothPeripheralCallback() {
override fun onDescriptorRead(
peripheral: BluetoothPeripheral,
value: ByteArray?,
descriptor: BluetoothGattDescriptor,
status: BluetoothCommandStatus
) {
logger.debug("DDD [${value?.joinToString(separator = ",") { eachByte -> "%02x".format(eachByte) }}] ($status)")
}
override fun onServicesDiscovered(peripheral: BluetoothPeripheral, services: MutableList<BluetoothGattService>) { override fun onServicesDiscovered(peripheral: BluetoothPeripheral, services: MutableList<BluetoothGattService>) {
logger.info("Checking device...") logger.info("Checking device...")
for (srv in services) {
for (chr in srv.characteristics) {
println("${srv.uuid} -> ${chr.uuid} (${chr.descriptors.joinToString(",") { it.uuid.toString() }})")
}
}
val service1 = services.firstOrNull { it.uuid == S1_SERVICE } val service1 = services.firstOrNull { it.uuid == S1_SERVICE }
val service2 = services.firstOrNull { it.uuid == S2_SERVICE }
if (service1 != null && service2 != null) {
if (service1 != null) {
btCommandInput = service1.getCharacteristic(S1_CHAR_COMMAND_INPUT) btCommandInput = service1.getCharacteristic(S1_CHAR_COMMAND_INPUT)
val s1out = service1.getCharacteristic(S1_CHAR_DATA_OUTPUT) val s1out = service1.getCharacteristic(S1_CHAR_DATA_OUTPUT)
val s1mys = service1.getCharacteristic(S1_CHAR_MYSTERY_OUTPUT)
val s2mys = service2.getCharacteristic(S2_CHAR_MYSTERY_OUTPUT)
if (btCommandInput == null || s1out == null || s1mys == null || s2mys == null) {
if (btCommandInput == null || s1out == null) {
logger.warn("Something is off about this i-CONSOLE device") logger.warn("Something is off about this i-CONSOLE device")
output.emitBlocking(ErrorOccurred("(Maybe) a malfunctioning i-CONSOLE device")) output.emitBlocking(ErrorOccurred("(Maybe) a malfunctioning i-CONSOLE device"))
peripheral.cancelConnection() peripheral.cancelConnection()
return return
} }
val otherSrv = peripheral.getService(UUID.fromString("02f00000-0000-0000-0000-00000000fe00"))
val otherChr = otherSrv!!.getCharacteristic(UUID.fromString("02f00000-0000-0000-0000-00000000ff02"))
for (desc in otherChr!!.descriptors) {
if (s1out.descriptors.none { it.uuid == desc.uuid }) {
s1out.addDescriptor(desc)
}
}
peripheral.setNotify(btCommandInput!!, true) peripheral.setNotify(btCommandInput!!, true)
peripheral.setNotify(otherChr!!, true)
peripheral.setNotify(s1out, true) peripheral.setNotify(s1out, true)
peripheral.setNotify(s1mys, true)
peripheral.setNotify(s2mys, true)
logger.info("Device setup successfully; sending first request...") logger.info("Device setup successfully; sending first request...")
runBlocking {
peripheral.writeCharacteristic(
btCommandInput!!,
AckRequest.toBytes(),
WITHOUT_RESPONSE,
)
}
send(AckRequest, to = peripheral)
current = peripheral current = peripheral
} else { } else {
@ -145,6 +158,7 @@ class IConsole : ActiveDriver() {
status: BluetoothCommandStatus, status: BluetoothCommandStatus,
) { ) {
val res = value?.let { Response. fromBytes(it) } val res = value?.let { Response. fromBytes(it) }
logger.debug("<<< [${value?.joinToString(separator = ",") { eachByte -> "%02x".format(eachByte) }}] = $res")
if (!connected && res is AckResponse) { if (!connected && res is AckResponse) {
logger.info("Device is now ready") logger.info("Device is now ready")
@ -189,6 +203,18 @@ class IConsole : ActiveDriver() {
) )
} }
} }
if (res is ClientIdsResponse) {
if (res.clientId != defaultClientId) {
defaultClientId = res.clientId
defaultMeterId = res.meterId
send(AckRequest)
}
}
if (res is UnknownResponse) {
logger.warn(res)
}
} }
} }
@ -255,7 +281,7 @@ class IConsole : ActiveDriver() {
if (current == null || btCommandInput === null || running) return if (current == null || btCommandInput === null || running) return
if (lastTime == 0) { if (lastTime == 0) {
logger.info("STARTED")
logger.debug("STARTED")
queue += AckRequest queue += AckRequest
queue += SetWorkoutModeRequest(0) queue += SetWorkoutModeRequest(0)
queue += SetWorkoutParamsRequest() queue += SetWorkoutParamsRequest()
@ -266,7 +292,7 @@ class IConsole : ActiveDriver() {
bonusLevel = 0 bonusLevel = 0
} }
} else { } else {
logger.info("RESUMED")
logger.debug("RESUMED")
queue += SetWorkoutControlStateRequest(1) queue += SetWorkoutControlStateRequest(1)
} }
} }
@ -291,8 +317,6 @@ class IConsole : ActiveDriver() {
override suspend fun onTick(output: FlowBus<Event>) { override suspend fun onTick(output: FlowBus<Event>) {
current?.apply { current?.apply {
if (connected && btCommandInput != null) { if (connected && btCommandInput != null) {
val s1input = btCommandInput!!
// Fetch one queued event when running, otherwise take up to five at a time // Fetch one queued event when running, otherwise take up to five at a time
val max = if (running) 1 else 5 val max = if (running) 1 else 5
for (i in 0 until max) { for (i in 0 until max) {
@ -301,26 +325,18 @@ class IConsole : ActiveDriver() {
} }
val first = queue.first() val first = queue.first()
writeCharacteristic(s1input, first.toBytes(), WITHOUT_RESPONSE)
send(first)
queue -= first queue -= first
delay(pollDuration) delay(pollDuration)
} }
if (running) { if (running) {
// Fetch current state // Fetch current state
writeCharacteristic(
s1input,
GetWorkoutStateRequest.toBytes(),
WITHOUT_RESPONSE,
)
send(GetWorkoutStateRequest)
} else { } else {
// This is just to keep the connection alive, as far as I know… // This is just to keep the connection alive, as far as I know…
runBlocking { runBlocking {
writeCharacteristic(
s1input,
GetMaxLevelRequest.toBytes(),
WITHOUT_RESPONSE,
)
send(GetMaxLevelRequest)
delay(pollDuration) delay(pollDuration)
} }
} }
@ -350,14 +366,21 @@ class IConsole : ActiveDriver() {
} }
} }
companion object {
private val S1_SERVICE = UUID.fromString("49535343-fe7d-4ae5-8fa9-9fafd205e455")
private val S1_CHAR_COMMAND_INPUT = UUID.fromString("49535343-8841-43f4-a8d4-ecbe34729bb3")
private val S1_CHAR_DATA_OUTPUT = UUID.fromString("49535343-1e4d-4bd9-ba61-23c647249616")
private val S1_CHAR_MYSTERY_OUTPUT = UUID.fromString("49535343-4c8a-39b3-2f49-511cff073b7e")
private fun send(req: Request, to: BluetoothPeripheral? = null) {
if ((to ?: current) != null && btCommandInput != null) {
val bytes = req.toBytes()
(to ?: current)!!.writeCharacteristic(btCommandInput!!, bytes, WITHOUT_RESPONSE)
logger.debug(">>> [${bytes.joinToString(separator = ",") { eachByte -> "%02x".format(eachByte) }}] = $req")
} else {
logger.warn("Peripheral not present!")
}
}
private val S2_SERVICE = UUID.fromString("49535343-5d82-6099-9348-7aac4d5fbc51")
private val S2_CHAR_MYSTERY_OUTPUT = UUID.fromString("49535343-026e-3a9b-954c-97daef17e26e")
companion object {
private val S1_SERVICE = UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb")
private val S1_CHAR_COMMAND_INPUT = UUID.fromString("0000fff2-0000-1000-8000-00805f9b34fb")
private val S1_CHAR_DATA_OUTPUT = UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb")
private val S1_DESCRIPTOR = UUID.fromString("00002802-0000-1000-8000-00805f9b34fb")
private val pollDuration: Duration = 500.milliseconds private val pollDuration: Duration = 500.milliseconds
} }

8
ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/iconsole/Request.kt

@ -3,6 +3,9 @@ package net.aiterp.git.ykonsole2.infrastructure.iconsole
import net.aiterp.git.ykonsole2.infrastructure.iconsole.AckRequest.asOneByte import net.aiterp.git.ykonsole2.infrastructure.iconsole.AckRequest.asOneByte
import net.aiterp.git.ykonsole2.infrastructure.iconsole.AckRequest.asTwoBytes import net.aiterp.git.ykonsole2.infrastructure.iconsole.AckRequest.asTwoBytes
internal var defaultClientId = 0
internal var defaultMeterId = 0
sealed class Request( sealed class Request(
private val actionId: Int, private val actionId: Int,
private val bytes: ByteArray = byteArrayOf(), private val bytes: ByteArray = byteArrayOf(),
@ -13,8 +16,8 @@ sealed class Request(
val prefix = byteArrayOf( val prefix = byteArrayOf(
(0xf0).asOneByte(false), (0xf0).asOneByte(false),
(actionId).asOneByte(false), (actionId).asOneByte(false),
(clientId).asOneByte(true),
(meterId).asOneByte(true),
maxOf(defaultClientId, clientId).asOneByte(true),
maxOf(defaultMeterId, meterId).asOneByte(true),
) )
val checkSum = (prefix + bytes).asSequence() val checkSum = (prefix + bytes).asSequence()
@ -28,6 +31,7 @@ sealed class Request(
protected fun Int.asOneByte(addOne: Boolean = true) = ((if (addOne) 0x01 else 0x00) + this % 256).toByte() protected fun Int.asOneByte(addOne: Boolean = true) = ((if (addOne) 0x01 else 0x00) + this % 256).toByte()
protected fun Int.asTwoBytes() = byteArrayOf((this / 100).asOneByte(), (this % 100).asOneByte()) protected fun Int.asTwoBytes() = byteArrayOf((this / 100).asOneByte(), (this % 100).asOneByte())
override fun toString(): String = javaClass.simpleName
override fun hashCode(): Int = bytes.contentHashCode() override fun hashCode(): Int = bytes.contentHashCode()
override fun equals(other: Any?) = other is Request && other.bytes.contentEquals(bytes) override fun equals(other: Any?) = other is Request && other.bytes.contentEquals(bytes)
} }

4
ykonsole-iconsole/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/iconsole/Response.kt

@ -17,8 +17,8 @@ sealed class Response {
fun fromBytes(bytes: ByteArray): Response { fun fromBytes(bytes: ByteArray): Response {
val kind = bytes[1] val kind = bytes[1]
val clientId = bytes[2]
val meterId = bytes[3]
val clientId = bytes[2] - 1
val meterId = bytes[3] - 1
val params = bytes.slice(4 until bytes.size - 1) val params = bytes.slice(4 until bytes.size - 1)
return when (kind) { return when (kind) {

37
ykonsole-jarun/pom.xml

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

74
ykonsole-jarun/src/main/kotlin/net/aiterp/git/ykonsole2/JarunDemo.kt

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

121
ykonsole-jarun/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/Jarun.kt

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

61
ykonsole-jarun/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/Types.kt

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

2
ykonsole-ktor/pom.xml

@ -15,7 +15,7 @@
<maven.compiler.source>11</maven.compiler.source> <maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target> <maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<ktor.version>2.0.3</ktor.version>
<ktor.version>2.3.11</ktor.version>
</properties> </properties>
<dependencies> <dependencies>

4
ykonsole-server/pom.xml

@ -25,12 +25,12 @@
</dependency> </dependency>
<dependency> <dependency>
<groupId>net.aiterp.git.trimlog</groupId> <groupId>net.aiterp.git.trimlog</groupId>
<artifactId>ykonsole-exporter</artifactId>
<artifactId>ykonsole-iconsole</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>net.aiterp.git.trimlog</groupId> <groupId>net.aiterp.git.trimlog</groupId>
<artifactId>ykonsole-iconsole</artifactId>
<artifactId>ykonsole-jarun</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency> <dependency>

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

@ -2,22 +2,18 @@ package net.aiterp.git.ykonsole2
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import net.aiterp.git.ykonsole2.application.createServer 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.env.strEnv
import net.aiterp.git.ykonsole2.application.logging.log
import net.aiterp.git.ykonsole2.application.plugins.ykObjectMapper
import net.aiterp.git.ykonsole2.application.services.DriverStarter import net.aiterp.git.ykonsole2.application.services.DriverStarter
import net.aiterp.git.ykonsole2.domain.models.* import net.aiterp.git.ykonsole2.domain.models.*
import net.aiterp.git.ykonsole2.domain.runtime.CommandBus import net.aiterp.git.ykonsole2.domain.runtime.CommandBus
import net.aiterp.git.ykonsole2.domain.runtime.EventBus import net.aiterp.git.ykonsole2.domain.runtime.EventBus
import net.aiterp.git.ykonsole2.infrastructure.ExportTarget
import net.aiterp.git.ykonsole2.infrastructure.IConsole import net.aiterp.git.ykonsole2.infrastructure.IConsole
import net.aiterp.git.ykonsole2.infrastructure.WorkoutExporter
import net.aiterp.git.ykonsole2.infrastructure.Jarun
import net.aiterp.git.ykonsole2.infrastructure.drivers.MilestoneChecker import net.aiterp.git.ykonsole2.infrastructure.drivers.MilestoneChecker
import net.aiterp.git.ykonsole2.infrastructure.drivers.ProgramEnforcer import net.aiterp.git.ykonsole2.infrastructure.drivers.ProgramEnforcer
import net.aiterp.git.ykonsole2.infrastructure.drivers.Skipper import net.aiterp.git.ykonsole2.infrastructure.drivers.Skipper
import net.aiterp.git.ykonsole2.infrastructure.drivers.WorkoutWriter 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.makeDataSource
import net.aiterp.git.ykonsole2.infrastructure.repositories.deviceRepo import net.aiterp.git.ykonsole2.infrastructure.repositories.deviceRepo
import net.aiterp.git.ykonsole2.infrastructure.repositories.programRepo import net.aiterp.git.ykonsole2.infrastructure.repositories.programRepo
@ -36,8 +32,8 @@ fun main(): Unit = runBlocking {
workoutRepo.save(active) workoutRepo.save(active)
} }
val exporter = workoutExporterOrNull()
val iConsole = IConsole() val iConsole = IConsole()
val jarun = Jarun(ykObjectMapper)
val milestoneChecker = MilestoneChecker() val milestoneChecker = MilestoneChecker()
val programEnforcer = ProgramEnforcer(programRepo, workoutRepo) val programEnforcer = ProgramEnforcer(programRepo, workoutRepo)
val skipper = Skipper() val skipper = Skipper()
@ -55,8 +51,8 @@ fun main(): Unit = runBlocking {
DriverStarter( DriverStarter(
drivers = listOfNotNull( drivers = listOfNotNull(
exporter,
iConsole, iConsole,
jarun,
milestoneChecker, milestoneChecker,
programEnforcer, programEnforcer,
skipper, skipper,
@ -89,37 +85,4 @@ private data class RepositorySet(
val programRepo: ProgramRepository, val programRepo: ProgramRepository,
val workoutRepo: WorkoutRepository, val workoutRepo: WorkoutRepository,
val workoutStateRepo: WorkoutStateRepository, val workoutStateRepo: WorkoutStateRepository,
) {
fun workoutExporterOrNull(): WorkoutExporter? {
val exportTarget = makeExportTarget() ?: return null
log.info("Export target set: ${exportTarget.javaClass.simpleName}")
return WorkoutExporter(workoutRepo, workoutStateRepo, deviceRepo, programRepo, exportTarget)
}
private fun makeExportTarget(): ExportTarget? {
val indigo1Endpoint = optStrEnv("INDIGO1_ENDPOINT")
if (indigo1Endpoint != null) {
val clientId = strEnv("INDIGO1_CLIENT_ID")
val clientSecret = strEnv("INDIGO1_CLIENT_SECRET")
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