@@ -139,7 +202,7 @@ function HealthBarProgress({progress}: ProgressProps) {
)}
{stepIndex > progress.currentIndex && (
-
+
)}
{stepIndex === progress.currentIndex && (
<>
diff --git a/webui-react/src/primitives/blob/Blob.sass b/webui-react/src/primitives/blob/Blob.sass
index cdfec6e..1a320ea 100644
--- a/webui-react/src/primitives/blob/Blob.sass
+++ b/webui-react/src/primitives/blob/Blob.sass
@@ -4,6 +4,7 @@
margin-top: 0.5em
border-radius: 0.5em
display: inline-block
+ box-sizing: border-box
cursor: default
margin-left: 0.25em
margin-right: 0.25em
@@ -86,6 +87,7 @@
.BlobText
padding: 0.5em
+ min-width: 1ch
&.BlobText-centered
text-align: center
diff --git a/webui-react/src/primitives/blob/Blob.tsx b/webui-react/src/primitives/blob/Blob.tsx
index c1ada5c..b82693f 100644
--- a/webui-react/src/primitives/blob/Blob.tsx
+++ b/webui-react/src/primitives/blob/Blob.tsx
@@ -93,9 +93,10 @@ interface BaseBlobInputProps
{
value: V
disabled?: boolean
onChange?: (newValue: V) => void
+ placeholder?: string
}
-export function BlobInput({type, name, flex, disabled, value, onChange}: BlobInputProps) {
+export function BlobInput({type, name, flex, disabled, value, onChange, placeholder}: BlobInputProps) {
const actualOnChange = useCallback((input: string) => {
if (onChange === undefined) return;
@@ -108,12 +109,13 @@ export function BlobInput({type, name, flex, disabled, value, onChange}: BlobInp
return (
actualOnChange(e.target.value || "")}
/>
);
diff --git a/webui-react/src/primitives/boi/Boi.sass b/webui-react/src/primitives/boi/Boi.sass
index fd62c0c..c8f6696 100644
--- a/webui-react/src/primitives/boi/Boi.sass
+++ b/webui-react/src/primitives/boi/Boi.sass
@@ -3,9 +3,11 @@
.Boi
background-color: rgba(0, 0, 0, 0.33)
z-index: 9999
- padding: 0.25vmax 0.75vmax 1.5vmax
box-sizing: border-box
+ &.Boi-chunky
+ padding: 0.25vmax 0.75vmax 1.5vmax
+
.TitleLine
margin-bottom: 0.25em
diff --git a/webui-react/src/primitives/boi/Boi.tsx b/webui-react/src/primitives/boi/Boi.tsx
index 7c0bc7a..20e58fe 100644
--- a/webui-react/src/primitives/boi/Boi.tsx
+++ b/webui-react/src/primitives/boi/Boi.tsx
@@ -5,11 +5,12 @@ import {useMemo} from "react";
interface BoiProps extends WithChildren, WithStyle {
vertical: "top" | "center" | "bottom"
horizontal: "left" | "center" | "right"
+ unchunky?: boolean
}
const defaultStyle = {fontSize: "3vmax"};
-export function Boi({horizontal, vertical, children, style}: BoiProps) {
+export function Boi({horizontal, vertical, unchunky, children, style}: BoiProps) {
const className = useMemo(() => {
const list = [
"Boi",
@@ -17,6 +18,10 @@ export function Boi({horizontal, vertical, children, style}: BoiProps) {
`Boi-v-${vertical}`,
];
+ if (!unchunky) {
+ list.push("Boi-chunky");
+ }
+
return list.join(" ");
}, [horizontal, vertical]);
diff --git a/webui-react/src/primitives/misc/Misc.tsx b/webui-react/src/primitives/misc/Misc.tsx
index 51a6778..3efa5ee 100644
--- a/webui-react/src/primitives/misc/Misc.tsx
+++ b/webui-react/src/primitives/misc/Misc.tsx
@@ -1,8 +1,44 @@
import "./Misc.sass";
import {WithChildren} from "../Shared";
+import {Values} from "../../models/Shared";
export function TitleLine({children}: WithChildren) {
return (
{children}
);
}
+
+interface ValueProps {
+ raw: Values | number
+ valueKey: keyof Values
+}
+
+export function Value({raw, valueKey}: ValueProps): JSX.Element | null {
+ const actual = typeof raw === "number" ? raw : (raw[valueKey]);
+
+ if (actual !== null && actual !== undefined) {
+ if (valueKey === "time") {
+ const minutes = Math.floor(actual / 60).toString(10).padStart(2, "0");
+ const seconds = (actual % 60).toString(10).padStart(2, "0");
+
+ return <>{minutes}'{seconds}">;
+ }
+
+ if (valueKey === "calories") {
+ return <>{actual} kcal>;
+ }
+
+ if (valueKey === "distance") {
+ const km = actual / 1000;
+ const kmStr = km > 9.95 ? km.toFixed(1) : km.toFixed(2);
+
+ return <>{kmStr} km>;
+ }
+
+ if (valueKey === "level") {
+ return <>{actual} lvl>;
+ }
+ }
+
+ return null;
+}
diff --git a/ykonsole-core/pom.xml b/ykonsole-core/pom.xml
index 938d659..39d11bf 100644
--- a/ykonsole-core/pom.xml
+++ b/ykonsole-core/pom.xml
@@ -23,6 +23,11 @@
kotlinx-coroutines-core-jvm
1.6.4
+
+ org.jetbrains.kotlinx
+ kotlinx-coroutines-jdk8
+ 1.6.4
+
diff --git a/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/YKonsoleException.kt b/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/YKonsoleException.kt
index decea96..c3bb4da 100644
--- a/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/YKonsoleException.kt
+++ b/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/YKonsoleException.kt
@@ -10,4 +10,4 @@ class InfrastructureException(cause: Throwable) : YKonsoleException(cause)
class BadInputException(message: String) : YKonsoleException(message)
-object BusyDriverException : YKonsoleException("Driver is busy")
+class StorageException(message: String) : YKonsoleException(message)
diff --git a/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/drivers/Skipper.kt b/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/drivers/Skipper.kt
index 0e0a4a1..5bebf7d 100644
--- a/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/drivers/Skipper.kt
+++ b/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/drivers/Skipper.kt
@@ -5,7 +5,7 @@ import net.aiterp.git.ykonsole2.domain.runtime.*
import net.aiterp.git.ykonsole2.infrastructure.drivers.abstracts.ActiveDriver
import kotlin.time.Duration.Companion.seconds
-object Skipper : ActiveDriver() {
+class Skipper : ActiveDriver() {
private var enabled: Boolean = false
override suspend fun onCommand(command: Command, output: FlowBus) {
diff --git a/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryDeviceRepository.kt b/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryDeviceRepository.kt
new file mode 100644
index 0000000..729610b
--- /dev/null
+++ b/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryDeviceRepository.kt
@@ -0,0 +1,26 @@
+package net.aiterp.git.ykonsole2.infrastructure.testing
+
+import net.aiterp.git.ykonsole2.domain.models.Device
+import net.aiterp.git.ykonsole2.domain.models.DeviceRepository
+
+class InMemoryDeviceRepository : DeviceRepository {
+ private val devices = mutableListOf()
+
+ override fun findById(id: String): Device? = devices.firstOrNull { it.id == id }
+
+ override fun fetchAll() = devices.map(Device::copy)
+
+ override fun save(device: Device) {
+ val index = devices.indexOfFirst { it.id == device.id }
+
+ if (index >= 0) {
+ devices[index] = device
+ } else {
+ devices += device
+ }
+ }
+
+ override fun delete(device: Device) {
+ devices.removeIf { it.id == device.id }
+ }
+}
diff --git a/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryProgramRepository.kt b/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryProgramRepository.kt
new file mode 100644
index 0000000..efb5f1d
--- /dev/null
+++ b/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryProgramRepository.kt
@@ -0,0 +1,24 @@
+package net.aiterp.git.ykonsole2.infrastructure.testing
+
+import net.aiterp.git.ykonsole2.domain.models.Program
+import net.aiterp.git.ykonsole2.domain.models.ProgramRepository
+
+class InMemoryProgramRepository : ProgramRepository {
+ private val programs = mutableListOf()
+
+ override fun findById(id: String): Program? = programs.firstOrNull { it.id == id }
+
+ override fun fetchAll(): List = programs.asSequence()
+ .map(Program::copy)
+ .sortedBy { it.name }
+ .toList()
+
+ override fun save(program: Program) {
+ programs.removeIf { it.id == program.id }
+ programs += program
+ }
+
+ override fun delete(program: Program) {
+ programs.removeIf { it.id == program.id }
+ }
+}
\ No newline at end of file
diff --git a/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryWorkoutRepository.kt b/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryWorkoutRepository.kt
new file mode 100644
index 0000000..6d1de10
--- /dev/null
+++ b/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryWorkoutRepository.kt
@@ -0,0 +1,30 @@
+package net.aiterp.git.ykonsole2.infrastructure.testing
+
+import net.aiterp.git.ykonsole2.domain.models.Workout
+import net.aiterp.git.ykonsole2.domain.models.WorkoutRepository
+import net.aiterp.git.ykonsole2.domain.models.WorkoutStatus
+
+class InMemoryWorkoutRepository : WorkoutRepository {
+ private val workouts = mutableListOf()
+
+ override fun findById(id: String): Workout? = workouts.firstOrNull { it.id == id }
+
+ override fun fetchAll(): List = workouts.asSequence()
+ .map { it.copy() }
+ .sortedByDescending { it.createdAt }
+ .toList()
+
+ override fun findActive(): Workout? = workouts.asSequence()
+ .filter { it.status != WorkoutStatus.Disconnected }
+ .sortedByDescending { it.createdAt }
+ .firstOrNull()
+
+ override fun save(workout: Workout) {
+ workouts.removeIf { it.id == workout.id }
+ workouts += workout
+ }
+
+ override fun delete(workout: Workout) {
+ workouts.removeIf { it.id == workout.id }
+ }
+}
diff --git a/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryWorkoutStateRepository.kt b/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryWorkoutStateRepository.kt
new file mode 100644
index 0000000..fd6c650
--- /dev/null
+++ b/ykonsole-core/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/testing/InMemoryWorkoutStateRepository.kt
@@ -0,0 +1,28 @@
+package net.aiterp.git.ykonsole2.infrastructure.testing
+
+import net.aiterp.git.ykonsole2.domain.models.WorkoutState
+import net.aiterp.git.ykonsole2.domain.models.WorkoutStateRepository
+
+class InMemoryWorkoutStateRepository : WorkoutStateRepository {
+ private val map = mutableMapOf>()
+
+ override fun fetchByWorkoutId(workoutId: String): List {
+ return map[workoutId]?.sortedBy { it.time.toInt() } ?: emptyList()
+ }
+
+ override fun save(state: WorkoutState) {
+ if (map[state.workoutId] != null) {
+ val list = map[state.workoutId]!!
+ list.removeIf { it.time == state.time }
+ list += state
+ } else {
+ map[state.workoutId] = mutableListOf(state)
+ }
+ }
+
+ override fun deleteAll(states: Collection) {
+ for (state in states) {
+ map[state.workoutId]?.removeIf { it.time == state.time }
+ }
+ }
+}
diff --git a/ykonsole-exporter/pom.xml b/ykonsole-exporter/pom.xml
new file mode 100644
index 0000000..2da9312
--- /dev/null
+++ b/ykonsole-exporter/pom.xml
@@ -0,0 +1,52 @@
+
+
+
+ ykonsole
+ net.aiterp.git.trimlog
+ 2.0.0
+
+ 4.0.0
+
+ ykonsole-exporter
+
+
+ 11
+ 11
+
+
+
+
+
+ false
+
+ central
+ bintray
+ https://jcenter.bintray.com
+
+
+
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ 2.13.3
+
+
+ com.fasterxml.jackson.module
+ jackson-module-kotlin
+ 2.13.3
+
+
+ net.aiterp.git.trimlog
+ ykonsole-core
+ ${project.version}
+
+
+ me.lazmaid.kraph
+ kraph
+ 0.6.1
+
+
+
diff --git a/ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/ExportTarget.kt b/ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/ExportTarget.kt
new file mode 100644
index 0000000..1381901
--- /dev/null
+++ b/ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/ExportTarget.kt
@@ -0,0 +1,23 @@
+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,
+ device: Device?,
+ program: Program?,
+ )
+}
diff --git a/ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/WorkoutExporter.kt b/ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/WorkoutExporter.kt
new file mode 100644
index 0000000..45f4243
--- /dev/null
+++ b/ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/WorkoutExporter.kt
@@ -0,0 +1,57 @@
+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) {
+ if (event is Connected) {
+ workoutId = workoutRepo.findActive()?.id ?: ""
+ }
+
+ if (event is Disconnected && workoutId != "") {
+ export(workoutId)
+ workoutId = ""
+ }
+ }
+
+ override suspend fun start(input: FlowBus, output: FlowBus) {
+ log.info("Checking recent workouts...")
+ val yesterday = Instant.now().minus(24, 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)
+ }
+}
diff --git a/ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/indigo1/Indigo1.kt b/ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/indigo1/Indigo1.kt
new file mode 100644
index 0000000..4e438bc
--- /dev/null
+++ b/ykonsole-exporter/src/main/kotlin/net/aiterp/git/ykonsole2/infrastructure/indigo1/Indigo1.kt
@@ -0,0 +1,177 @@
+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,
+ 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? = null,
+ val addMetadataBatch: List? = null,
+ val addTagBatch: List? = null,
+ val exercises: List? = 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)
+}
\ No newline at end of file
diff --git a/ykonsole-server/pom.xml b/ykonsole-server/pom.xml
index d710b30..308b422 100644
--- a/ykonsole-server/pom.xml
+++ b/ykonsole-server/pom.xml
@@ -23,6 +23,11 @@
ykonsole-core
${project.version}
+
+ net.aiterp.git.trimlog
+ ykonsole-exporter
+ ${project.version}
+
net.aiterp.git.trimlog
ykonsole-iconsole
diff --git a/ykonsole-server/src/main/kotlin/net/aiterp/git/ykonsole2/Server.kt b/ykonsole-server/src/main/kotlin/net/aiterp/git/ykonsole2/Server.kt
index b4e761d..42215be 100644
--- a/ykonsole-server/src/main/kotlin/net/aiterp/git/ykonsole2/Server.kt
+++ b/ykonsole-server/src/main/kotlin/net/aiterp/git/ykonsole2/Server.kt
@@ -2,28 +2,29 @@ package net.aiterp.git.ykonsole2
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.services.DriverStarter
-import net.aiterp.git.ykonsole2.domain.models.WorkoutStatus
+import net.aiterp.git.ykonsole2.domain.models.*
import net.aiterp.git.ykonsole2.domain.runtime.CommandBus
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.WorkoutExporter
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.makeDataSource
import net.aiterp.git.ykonsole2.infrastructure.repositories.deviceRepo
import net.aiterp.git.ykonsole2.infrastructure.repositories.programRepo
import net.aiterp.git.ykonsole2.infrastructure.repositories.workoutRepo
import net.aiterp.git.ykonsole2.infrastructure.repositories.workoutStateRepo
-import net.aiterp.git.ykonsole2.infrastructure.testing.TestDriver
+import net.aiterp.git.ykonsole2.infrastructure.testing.*
import kotlin.time.Duration.Companion.seconds
fun main(): Unit = runBlocking {
- makeDataSource(
- url = strEnv("MYSQL_URL"),
- username = strEnv("MYSQL_USERNAME"),
- password = strEnv("MYSQL_PASSWORD"),
- ).apply {
+ initRepositories().apply {
val commandBus = CommandBus()
val eventBus = EventBus()
@@ -32,8 +33,10 @@ fun main(): Unit = runBlocking {
workoutRepo.save(active)
}
+ val exporter = workoutExporterOrNull()
val iConsole = IConsole()
val programEnforcer = ProgramEnforcer(programRepo, workoutRepo)
+ val skipper = Skipper()
val testDriver = TestDriver(secondLength = 1.seconds)
val workoutWriter = WorkoutWriter(workoutRepo, workoutStateRepo)
@@ -47,9 +50,56 @@ fun main(): Unit = runBlocking {
).start(wait = false)
DriverStarter(
- drivers = listOf(testDriver, iConsole, workoutWriter, programEnforcer),
+ drivers = listOfNotNull(
+ exporter,
+ iConsole,
+ programEnforcer,
+ skipper,
+ testDriver,
+ workoutWriter,
+ ),
input = commandBus,
output = eventBus,
).startDrivers()
}
}
+
+private fun initRepositories(): RepositorySet = when (val storageType = strEnv("STORAGE_TYPE")) {
+ "in_memory" -> RepositorySet(
+ deviceRepo = InMemoryDeviceRepository(),
+ programRepo = InMemoryProgramRepository(),
+ workoutRepo = InMemoryWorkoutRepository(),
+ workoutStateRepo = InMemoryWorkoutStateRepository(),
+ )
+ "mysql" -> makeDataSource(
+ url = strEnv("MYSQL_URL"),
+ username = strEnv("MYSQL_USERNAME"),
+ password = strEnv("MYSQL_PASSWORD"),
+ ).run { RepositorySet(deviceRepo, programRepo, workoutRepo, workoutStateRepo) }
+ else -> error("Invalid storage type: $storageType")
+}
+
+private data class RepositorySet(
+ val deviceRepo: DeviceRepository,
+ val programRepo: ProgramRepository,
+ val workoutRepo: WorkoutRepository,
+ val workoutStateRepo: WorkoutStateRepository,
+) {
+ fun workoutExporterOrNull(): WorkoutExporter? {
+ val exportTarget = makeExportTarget() ?: return null
+
+ 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)
+ }
+
+ return null
+ }
+}